mirror of
https://github.com/bitwarden/browser
synced 2025-12-29 14:43:31 +00:00
[PM-28450] Single integration service (#17925)
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
|
||||
import { OrgIntegrationConfiguration } from "../integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "../organization-integration-service-type";
|
||||
|
||||
export class DatadogConfiguration {
|
||||
export class DatadogConfiguration implements OrgIntegrationConfiguration {
|
||||
uri: string;
|
||||
apiKey: string;
|
||||
service: OrganizationIntegrationServiceType;
|
||||
service: OrganizationIntegrationServiceName;
|
||||
|
||||
constructor(uri: string, apiKey: string, service: string) {
|
||||
constructor(uri: string, apiKey: string, service: OrganizationIntegrationServiceName) {
|
||||
this.uri = uri;
|
||||
this.apiKey = apiKey;
|
||||
this.service = service as OrganizationIntegrationServiceType;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
|
||||
import { OrgIntegrationConfiguration } from "../integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "../organization-integration-service-type";
|
||||
|
||||
export class HecConfiguration {
|
||||
export class HecConfiguration implements OrgIntegrationConfiguration {
|
||||
uri: string;
|
||||
scheme = "Bearer";
|
||||
token: string;
|
||||
service: OrganizationIntegrationServiceType;
|
||||
service: OrganizationIntegrationServiceName;
|
||||
|
||||
constructor(uri: string, token: string, service: string) {
|
||||
constructor(uri: string, token: string, service: OrganizationIntegrationServiceName) {
|
||||
this.uri = uri;
|
||||
this.token = token;
|
||||
this.service = service as OrganizationIntegrationServiceType;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { OrgIntegrationConfiguration } from "../integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "../organization-integration-service-type";
|
||||
|
||||
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
|
||||
export class WebhookConfiguration {
|
||||
export class WebhookConfiguration implements OrgIntegrationConfiguration {
|
||||
propA: string;
|
||||
propB: string;
|
||||
service: OrganizationIntegrationServiceName;
|
||||
|
||||
constructor(propA: string, propB: string) {
|
||||
constructor(propA: string, propB: string, service: OrganizationIntegrationServiceName) {
|
||||
this.propA = propA;
|
||||
this.propB = propB;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { DatadogConfiguration } from "./configuration/datadog-configuration";
|
||||
import { HecConfiguration } from "./configuration/hec-configuration";
|
||||
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
|
||||
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
|
||||
import { OrganizationIntegrationServiceName } from "./organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "./organization-integration-type";
|
||||
|
||||
/**
|
||||
* Defines the structure for organization integration configuration
|
||||
*/
|
||||
export interface OrgIntegrationConfiguration {
|
||||
service: OrganizationIntegrationServiceName;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the structure for organization integration template
|
||||
*/
|
||||
export interface OrgIntegrationTemplate {
|
||||
service: OrganizationIntegrationServiceName;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for creating organization integration configurations and templates
|
||||
*/
|
||||
export class OrgIntegrationBuilder {
|
||||
static buildHecConfiguration(
|
||||
uri: string,
|
||||
token: string,
|
||||
service: OrganizationIntegrationServiceName,
|
||||
): OrgIntegrationConfiguration {
|
||||
return new HecConfiguration(uri, token, service);
|
||||
}
|
||||
|
||||
static buildHecTemplate(
|
||||
index: string,
|
||||
service: OrganizationIntegrationServiceName,
|
||||
): OrgIntegrationTemplate {
|
||||
return new HecTemplate(index, service);
|
||||
}
|
||||
|
||||
static buildDataDogConfiguration(uri: string, apiKey: string): OrgIntegrationConfiguration {
|
||||
return new DatadogConfiguration(uri, apiKey, OrganizationIntegrationServiceName.Datadog);
|
||||
}
|
||||
|
||||
static buildDataDogTemplate(service: OrganizationIntegrationServiceName): OrgIntegrationTemplate {
|
||||
return new DatadogTemplate(service);
|
||||
}
|
||||
|
||||
static buildConfiguration(
|
||||
type: OrganizationIntegrationType,
|
||||
configuration: string,
|
||||
): OrgIntegrationConfiguration {
|
||||
switch (type) {
|
||||
case OrganizationIntegrationType.Hec: {
|
||||
const hecConfig = this.convertToJson<HecConfiguration>(configuration);
|
||||
return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.service);
|
||||
}
|
||||
case OrganizationIntegrationType.Datadog: {
|
||||
const datadogConfig = this.convertToJson<DatadogConfiguration>(configuration);
|
||||
return this.buildDataDogConfiguration(datadogConfig.uri, datadogConfig.apiKey);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported integration type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
static buildTemplate(
|
||||
type: OrganizationIntegrationType,
|
||||
template: string,
|
||||
): OrgIntegrationTemplate {
|
||||
switch (type) {
|
||||
case OrganizationIntegrationType.Hec: {
|
||||
const hecTemplate = this.convertToJson<HecTemplate>(template);
|
||||
return this.buildHecTemplate(hecTemplate.index, hecTemplate.service);
|
||||
}
|
||||
case OrganizationIntegrationType.Datadog: {
|
||||
const datadogTemplate = this.convertToJson<DatadogTemplate>(template);
|
||||
return this.buildDataDogTemplate(datadogTemplate.service);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported integration type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static convertToJson<T>(jsonString?: string): T {
|
||||
try {
|
||||
return JSON.parse(jsonString || "{}") as T;
|
||||
} catch {
|
||||
throw new Error("Invalid integration configuration: JSON parse error");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
|
||||
import { OrgIntegrationTemplate } from "../../integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
|
||||
|
||||
export class DatadogTemplate {
|
||||
export class DatadogTemplate implements OrgIntegrationTemplate {
|
||||
source_type_name = "Bitwarden";
|
||||
title: string = "#Title#";
|
||||
text: string =
|
||||
"ActingUser: #ActingUserId#\nUser: #UserId#\nEvent: #Type#\nOrganization: #OrganizationId#\nPolicyId: #PolicyId#\nIpAddress: #IpAddress#\nDomainName: #DomainName#\nCipherId: #CipherId#\n";
|
||||
service: OrganizationIntegrationServiceType;
|
||||
service: OrganizationIntegrationServiceName;
|
||||
|
||||
constructor(service: string) {
|
||||
this.service = service as OrganizationIntegrationServiceType;
|
||||
constructor(service: OrganizationIntegrationServiceName) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
|
||||
import { OrgIntegrationTemplate } from "../../integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
|
||||
|
||||
export class HecTemplate {
|
||||
export class HecTemplate implements OrgIntegrationTemplate {
|
||||
event = "#EventMessage#";
|
||||
source = "Bitwarden";
|
||||
index: string;
|
||||
service: OrganizationIntegrationServiceType;
|
||||
service: OrganizationIntegrationServiceName;
|
||||
|
||||
constructor(index: string, service: string) {
|
||||
constructor(index: string, service: OrganizationIntegrationServiceName) {
|
||||
this.index = index;
|
||||
this.service = service as OrganizationIntegrationServiceType;
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { OrgIntegrationTemplate } from "../../integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
|
||||
|
||||
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
|
||||
export class WebhookTemplate {
|
||||
export class WebhookTemplate implements OrgIntegrationTemplate {
|
||||
service: OrganizationIntegrationServiceName;
|
||||
propA: string;
|
||||
propB: string;
|
||||
|
||||
constructor(propA: string, propB: string) {
|
||||
constructor(service: OrganizationIntegrationServiceName, propA: string, propB: string) {
|
||||
this.service = service;
|
||||
this.propA = propA;
|
||||
this.propB = propB;
|
||||
}
|
||||
|
||||
@@ -4,31 +4,25 @@ import {
|
||||
OrganizationIntegrationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
|
||||
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
|
||||
import { WebhookTemplate } from "./integration-configuration-config/configuration-template/webhook-template";
|
||||
import { WebhookIntegrationConfigurationConfig } from "./integration-configuration-config/webhook-integration-configuration-config";
|
||||
import { OrgIntegrationTemplate } from "./integration-builder";
|
||||
|
||||
export class OrganizationIntegrationConfiguration {
|
||||
id: OrganizationIntegrationConfigurationId;
|
||||
integrationId: OrganizationIntegrationId;
|
||||
eventType?: EventType | null;
|
||||
configuration?: WebhookIntegrationConfigurationConfig | null;
|
||||
filters?: string;
|
||||
template?: HecTemplate | WebhookTemplate | DatadogTemplate | null;
|
||||
template?: OrgIntegrationTemplate | null;
|
||||
|
||||
constructor(
|
||||
id: OrganizationIntegrationConfigurationId,
|
||||
integrationId: OrganizationIntegrationId,
|
||||
eventType?: EventType | null,
|
||||
configuration?: WebhookIntegrationConfigurationConfig | null,
|
||||
filters?: string,
|
||||
template?: HecTemplate | WebhookTemplate | DatadogTemplate | null,
|
||||
template?: OrgIntegrationTemplate | null,
|
||||
) {
|
||||
this.id = id;
|
||||
this.integrationId = integrationId;
|
||||
this.eventType = eventType;
|
||||
this.configuration = configuration;
|
||||
this.filters = filters;
|
||||
this.template = template;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const OrganizationIntegrationServiceType = Object.freeze({
|
||||
export const OrganizationIntegrationServiceName = Object.freeze({
|
||||
CrowdStrike: "CrowdStrike",
|
||||
Datadog: "Datadog",
|
||||
} as const);
|
||||
|
||||
export type OrganizationIntegrationServiceType =
|
||||
(typeof OrganizationIntegrationServiceType)[keyof typeof OrganizationIntegrationServiceType];
|
||||
export type OrganizationIntegrationServiceName =
|
||||
(typeof OrganizationIntegrationServiceName)[keyof typeof OrganizationIntegrationServiceName];
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import { OrganizationIntegrationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DatadogConfiguration } from "./configuration/datadog-configuration";
|
||||
import { HecConfiguration } from "./configuration/hec-configuration";
|
||||
import { WebhookConfiguration } from "./configuration/webhook-configuration";
|
||||
import { OrgIntegrationConfiguration } from "./integration-builder";
|
||||
import { OrganizationIntegrationConfiguration } from "./organization-integration-configuration";
|
||||
import { OrganizationIntegrationServiceType } from "./organization-integration-service-type";
|
||||
import { OrganizationIntegrationServiceName } from "./organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "./organization-integration-type";
|
||||
|
||||
export class OrganizationIntegration {
|
||||
id: OrganizationIntegrationId;
|
||||
type: OrganizationIntegrationType;
|
||||
serviceType: OrganizationIntegrationServiceType;
|
||||
configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null;
|
||||
serviceName: OrganizationIntegrationServiceName;
|
||||
configuration: OrgIntegrationConfiguration | null;
|
||||
integrationConfiguration: OrganizationIntegrationConfiguration[] = [];
|
||||
|
||||
constructor(
|
||||
id: OrganizationIntegrationId,
|
||||
type: OrganizationIntegrationType,
|
||||
serviceType: OrganizationIntegrationServiceType,
|
||||
configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null,
|
||||
serviceName: OrganizationIntegrationServiceName,
|
||||
configuration: OrgIntegrationConfiguration | null,
|
||||
integrationConfiguration: OrganizationIntegrationConfiguration[] = [],
|
||||
) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.serviceType = serviceType;
|
||||
this.serviceName = serviceName;
|
||||
this.configuration = configuration;
|
||||
this.integrationConfiguration = integrationConfiguration;
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
OrganizationIntegrationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DatadogConfiguration } from "../models/configuration/datadog-configuration";
|
||||
import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template";
|
||||
import { OrganizationIntegration } from "../models/organization-integration";
|
||||
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
|
||||
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
|
||||
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
|
||||
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { DatadogOrganizationIntegrationService } from "./datadog-organization-integration-service";
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
|
||||
|
||||
describe("DatadogOrganizationIntegrationService", () => {
|
||||
let service: DatadogOrganizationIntegrationService;
|
||||
const mockIntegrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
const mockIntegrationConfigurationApiService =
|
||||
mock<OrganizationIntegrationConfigurationApiService>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,350 +0,0 @@
|
||||
import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import {
|
||||
OrganizationId,
|
||||
OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DatadogConfiguration } from "../models/configuration/datadog-configuration";
|
||||
import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template";
|
||||
import { OrganizationIntegration } from "../models/organization-integration";
|
||||
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
|
||||
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
|
||||
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
|
||||
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
|
||||
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
|
||||
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
|
||||
|
||||
export type DatadogModificationFailureReason = {
|
||||
mustBeOwner: boolean;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export class DatadogOrganizationIntegrationService {
|
||||
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
|
||||
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
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<DatadogModificationFailureReason> {
|
||||
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<DatadogModificationFailureReason> {
|
||||
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<DatadogModificationFailureReason> {
|
||||
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<OrganizationIntegration | null> {
|
||||
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<OrganizationIntegration | null> {
|
||||
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<OrganizationIntegrationConfiguration[] | null> {
|
||||
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<DatadogConfiguration>(
|
||||
integrationResponse.configuration,
|
||||
);
|
||||
const template = this.convertToJson<DatadogTemplate>(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<void>[] = [];
|
||||
|
||||
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<T>(jsonString?: string): T | null {
|
||||
try {
|
||||
return JSON.parse(jsonString || "") as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
OrganizationIntegrationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import { HecConfiguration } from "../models/configuration/hec-configuration";
|
||||
import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template";
|
||||
import { OrganizationIntegration } from "../models/organization-integration";
|
||||
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
|
||||
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
|
||||
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
|
||||
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { HecOrganizationIntegrationService } from "./hec-organization-integration-service";
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
|
||||
|
||||
describe("HecOrganizationIntegrationService", () => {
|
||||
let service: HecOrganizationIntegrationService;
|
||||
const mockIntegrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
const mockIntegrationConfigurationApiService =
|
||||
mock<OrganizationIntegrationConfigurationApiService>();
|
||||
const organizationId = "org-1" as OrganizationId;
|
||||
const integrationId = "int-1" as OrganizationIntegrationId;
|
||||
const configId = "conf-1" as OrganizationIntegrationConfigurationId;
|
||||
const serviceType = OrganizationIntegrationServiceType.CrowdStrike;
|
||||
const url = "https://example.com";
|
||||
const bearerToken = "token";
|
||||
const index = "main";
|
||||
|
||||
beforeEach(() => {
|
||||
service = new HecOrganizationIntegrationService(
|
||||
mockIntegrationApiService,
|
||||
mockIntegrationConfigurationApiService,
|
||||
);
|
||||
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should set organization integrations", (done) => {
|
||||
mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
|
||||
service.setOrganizationIntegrations(organizationId);
|
||||
const subscription = service.integrations$.subscribe((integrations) => {
|
||||
expect(integrations).toEqual([]);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should save a new Hec integration", async () => {
|
||||
service.setOrganizationIntegrations(organizationId);
|
||||
|
||||
const integrationResponse = {
|
||||
id: integrationId,
|
||||
type: OrganizationIntegrationType.Hec,
|
||||
configuration: JSON.stringify({ url, bearerToken, service: serviceType }),
|
||||
} as OrganizationIntegrationResponse;
|
||||
|
||||
const configResponse = {
|
||||
id: configId,
|
||||
template: JSON.stringify({ index, service: serviceType }),
|
||||
} as OrganizationIntegrationConfigurationResponse;
|
||||
|
||||
mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse);
|
||||
mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
configResponse,
|
||||
);
|
||||
|
||||
await service.saveHec(organizationId, serviceType, url, bearerToken, index);
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations.length).toBe(1);
|
||||
expect(integrations[0].id).toBe(integrationId);
|
||||
expect(integrations[0].serviceType).toBe(serviceType);
|
||||
});
|
||||
|
||||
it("should throw error on organization ID mismatch in saveHec", async () => {
|
||||
service.setOrganizationIntegrations("other-org" as OrganizationId);
|
||||
await expect(
|
||||
service.saveHec(organizationId, serviceType, url, bearerToken, index),
|
||||
).rejects.toThrow(Error("Organization ID mismatch"));
|
||||
});
|
||||
|
||||
it("should update an existing Hec integration", async () => {
|
||||
service.setOrganizationIntegrations(organizationId);
|
||||
|
||||
const integrationResponse = {
|
||||
id: integrationId,
|
||||
type: OrganizationIntegrationType.Hec,
|
||||
configuration: JSON.stringify({ url, bearerToken, service: serviceType }),
|
||||
} as OrganizationIntegrationResponse;
|
||||
|
||||
const configResponse = {
|
||||
id: configId,
|
||||
template: JSON.stringify({ index, service: serviceType }),
|
||||
} as OrganizationIntegrationConfigurationResponse;
|
||||
|
||||
mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse);
|
||||
mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
configResponse,
|
||||
);
|
||||
|
||||
await service.updateHec(
|
||||
organizationId,
|
||||
integrationId,
|
||||
configId,
|
||||
serviceType,
|
||||
url,
|
||||
bearerToken,
|
||||
index,
|
||||
);
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations.length).toBe(1);
|
||||
expect(integrations[0].id).toBe(integrationId);
|
||||
});
|
||||
|
||||
it("should throw error on organization ID mismatch in updateHec", async () => {
|
||||
service.setOrganizationIntegrations("other-org" as OrganizationId);
|
||||
await expect(
|
||||
service.updateHec(
|
||||
organizationId,
|
||||
integrationId,
|
||||
configId,
|
||||
serviceType,
|
||||
url,
|
||||
bearerToken,
|
||||
index,
|
||||
),
|
||||
).rejects.toThrow(Error("Organization ID mismatch"));
|
||||
});
|
||||
|
||||
it("should get integration by id", async () => {
|
||||
service["_integrations$"].next([
|
||||
new OrganizationIntegration(
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
serviceType,
|
||||
{} as HecConfiguration,
|
||||
[],
|
||||
),
|
||||
]);
|
||||
const integration = await service.getIntegrationById(integrationId);
|
||||
expect(integration).not.toBeNull();
|
||||
expect(integration!.id).toBe(integrationId);
|
||||
});
|
||||
|
||||
it("should get integration by service type", async () => {
|
||||
service["_integrations$"].next([
|
||||
new OrganizationIntegration(
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
serviceType,
|
||||
{} as HecConfiguration,
|
||||
[],
|
||||
),
|
||||
]);
|
||||
const integration = await service.getIntegrationByServiceType(serviceType);
|
||||
expect(integration).not.toBeNull();
|
||||
expect(integration!.serviceType).toBe(serviceType);
|
||||
});
|
||||
|
||||
it("should get integration configurations", async () => {
|
||||
const config = new OrganizationIntegrationConfiguration(
|
||||
configId,
|
||||
integrationId,
|
||||
null,
|
||||
null,
|
||||
"",
|
||||
{} as HecTemplate,
|
||||
);
|
||||
|
||||
service["_integrations$"].next([
|
||||
new OrganizationIntegration(
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
serviceType,
|
||||
{} as HecConfiguration,
|
||||
[config],
|
||||
),
|
||||
]);
|
||||
const configs = await service.getIntegrationConfigurations(integrationId);
|
||||
expect(configs).not.toBeNull();
|
||||
expect(configs![0].id).toBe(configId);
|
||||
});
|
||||
|
||||
it("convertToJson should parse valid JSON", () => {
|
||||
const obj = service.convertToJson<{ a: number }>('{"a":1}');
|
||||
expect(obj).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("convertToJson should return null for invalid JSON", () => {
|
||||
const obj = service.convertToJson<{ a: number }>("invalid");
|
||||
expect(obj).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import {
|
||||
OrganizationId,
|
||||
OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import { HecConfiguration } from "../models/configuration/hec-configuration";
|
||||
import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template";
|
||||
import { OrganizationIntegration } from "../models/organization-integration";
|
||||
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
|
||||
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
|
||||
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
|
||||
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
|
||||
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
|
||||
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
|
||||
|
||||
export type HecModificationFailureReason = {
|
||||
mustBeOwner: boolean;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export class HecOrganizationIntegrationService {
|
||||
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
|
||||
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
integrations$ = this._integrations$.asObservable();
|
||||
|
||||
private fetch$ = this.organizationId$
|
||||
.pipe(
|
||||
switchMap(async (orgId) => {
|
||||
if (orgId) {
|
||||
const data$ = await this.setIntegrations(orgId);
|
||||
return await firstValueFrom(data$);
|
||||
} else {
|
||||
return [] as OrganizationIntegration[];
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe({
|
||||
next: (integrations) => {
|
||||
this._integrations$.next(integrations);
|
||||
},
|
||||
});
|
||||
|
||||
constructor(
|
||||
private integrationApiService: OrganizationIntegrationApiService,
|
||||
private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sets the organization Id and will trigger the retrieval of the
|
||||
* integrations for a given org.
|
||||
* @param orgId
|
||||
*/
|
||||
setOrganizationIntegrations(orgId: OrganizationId) {
|
||||
if (orgId == this.organizationId$.getValue()) {
|
||||
return;
|
||||
}
|
||||
this._integrations$.next([]);
|
||||
this.organizationId$.next(orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a new organization integration and updates the integrations$ observable
|
||||
* @param organizationId id of the organization
|
||||
* @param service service type of the integration
|
||||
* @param url url of the service
|
||||
* @param bearerToken api token
|
||||
* @param index index in service
|
||||
*/
|
||||
async saveHec(
|
||||
organizationId: OrganizationId,
|
||||
service: OrganizationIntegrationServiceType,
|
||||
url: string,
|
||||
bearerToken: string,
|
||||
index: string,
|
||||
): Promise<HecModificationFailureReason> {
|
||||
if (organizationId != this.organizationId$.getValue()) {
|
||||
throw new Error("Organization ID mismatch");
|
||||
}
|
||||
|
||||
try {
|
||||
const hecConfig = new HecConfiguration(url, bearerToken, service);
|
||||
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
|
||||
organizationId,
|
||||
new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()),
|
||||
);
|
||||
|
||||
const newTemplate = new HecTemplate(index, service);
|
||||
const newIntegrationConfigResponse =
|
||||
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
newIntegrationResponse.id,
|
||||
new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()),
|
||||
);
|
||||
|
||||
const newIntegration = this.mapResponsesToOrganizationIntegration(
|
||||
newIntegrationResponse,
|
||||
newIntegrationConfigResponse,
|
||||
);
|
||||
if (newIntegration !== null) {
|
||||
this._integrations$.next([...this._integrations$.getValue(), newIntegration]);
|
||||
}
|
||||
return { mustBeOwner: false, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
return { mustBeOwner: true, success: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing organization integration and updates the integrations$ observable
|
||||
* @param organizationId id of the organization
|
||||
* @param OrganizationIntegrationId id of the organization integration
|
||||
* @param OrganizationIntegrationConfigurationId id of the organization integration configuration
|
||||
* @param service service type of the integration
|
||||
* @param url url of the service
|
||||
* @param bearerToken api token
|
||||
* @param index index in service
|
||||
*/
|
||||
async updateHec(
|
||||
organizationId: OrganizationId,
|
||||
OrganizationIntegrationId: OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
|
||||
service: OrganizationIntegrationServiceType,
|
||||
url: string,
|
||||
bearerToken: string,
|
||||
index: string,
|
||||
): Promise<HecModificationFailureReason> {
|
||||
if (organizationId != this.organizationId$.getValue()) {
|
||||
throw new Error("Organization ID mismatch");
|
||||
}
|
||||
|
||||
try {
|
||||
const hecConfig = new HecConfiguration(url, bearerToken, service);
|
||||
const updatedIntegrationResponse =
|
||||
await this.integrationApiService.updateOrganizationIntegration(
|
||||
organizationId,
|
||||
OrganizationIntegrationId,
|
||||
new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()),
|
||||
);
|
||||
|
||||
const updatedTemplate = new HecTemplate(index, service);
|
||||
const updatedIntegrationConfigResponse =
|
||||
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
new OrganizationIntegrationConfigurationRequest(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
updatedTemplate.toString(),
|
||||
),
|
||||
);
|
||||
|
||||
const updatedIntegration = this.mapResponsesToOrganizationIntegration(
|
||||
updatedIntegrationResponse,
|
||||
updatedIntegrationConfigResponse,
|
||||
);
|
||||
|
||||
if (updatedIntegration !== null) {
|
||||
const unchangedIntegrations = this._integrations$
|
||||
.getValue()
|
||||
.filter((i) => i.id !== OrganizationIntegrationId);
|
||||
this._integrations$.next([...unchangedIntegrations, updatedIntegration]);
|
||||
}
|
||||
return { mustBeOwner: false, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
return { mustBeOwner: true, success: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteHec(
|
||||
organizationId: OrganizationId,
|
||||
OrganizationIntegrationId: OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
|
||||
): Promise<HecModificationFailureReason> {
|
||||
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<OrganizationIntegration | null> {
|
||||
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<OrganizationIntegration | null> {
|
||||
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<OrganizationIntegrationConfiguration[] | null> {
|
||||
return await firstValueFrom(
|
||||
this.integrations$.pipe(
|
||||
map((integrations) => {
|
||||
const integration = integrations.find((i) => i.id === integrationId);
|
||||
return integration ? integration.integrationConfiguration : null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Move to data models to be more explicit for future services
|
||||
private mapResponsesToOrganizationIntegration(
|
||||
integrationResponse: OrganizationIntegrationResponse,
|
||||
configurationResponse: OrganizationIntegrationConfigurationResponse,
|
||||
): OrganizationIntegration | null {
|
||||
const hecConfig = this.convertToJson<HecConfiguration>(integrationResponse.configuration);
|
||||
const template = this.convertToJson<HecTemplate>(configurationResponse.template);
|
||||
|
||||
if (!hecConfig || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const integrationConfig = new OrganizationIntegrationConfiguration(
|
||||
configurationResponse.id,
|
||||
integrationResponse.id,
|
||||
null,
|
||||
null,
|
||||
"",
|
||||
template,
|
||||
);
|
||||
|
||||
return new OrganizationIntegration(
|
||||
integrationResponse.id,
|
||||
integrationResponse.type,
|
||||
hecConfig.service,
|
||||
hecConfig,
|
||||
[integrationConfig],
|
||||
);
|
||||
}
|
||||
|
||||
// Could possibly be moved to a base service. All services would then assume that the
|
||||
// integration configuration would always be an array and this hec specific service
|
||||
// would just assume a single entry.
|
||||
private setIntegrations(orgId: OrganizationId) {
|
||||
const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe(
|
||||
switchMap(([responses]) => {
|
||||
const integrations: OrganizationIntegration[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
responses.forEach((integration) => {
|
||||
if (integration.type === OrganizationIntegrationType.Hec) {
|
||||
const promise = this.integrationConfigurationApiService
|
||||
.getOrganizationIntegrationConfigurations(orgId, integration.id)
|
||||
.then((response) => {
|
||||
// Hec events will only have one OrganizationIntegrationConfiguration
|
||||
const config = response[0];
|
||||
|
||||
const orgIntegration = this.mapResponsesToOrganizationIntegration(
|
||||
integration,
|
||||
config,
|
||||
);
|
||||
|
||||
if (orgIntegration !== null) {
|
||||
integrations.push(orgIntegration);
|
||||
}
|
||||
});
|
||||
promises.push(promise);
|
||||
}
|
||||
});
|
||||
return Promise.all(promises).then(() => {
|
||||
return integrations;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
|
||||
// TODO: Move to base service when necessary
|
||||
convertToJson<T>(jsonString?: string): T | null {
|
||||
try {
|
||||
return JSON.parse(jsonString || "") as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
|
||||
import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationServiceName } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
@@ -56,7 +56,7 @@ describe("OrganizationIntegrationApiService", () => {
|
||||
it("should call apiService.send with correct parameters for createOrganizationIntegration", async () => {
|
||||
const request = new OrganizationIntegrationRequest(
|
||||
OrganizationIntegrationType.Hec,
|
||||
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceType.CrowdStrike}' }`,
|
||||
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceName.CrowdStrike}' }`,
|
||||
);
|
||||
const orgId = "org1" as OrganizationId;
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("OrganizationIntegrationApiService", () => {
|
||||
it("should call apiService.send with the correct parameters for updateOrganizationIntegration", async () => {
|
||||
const request = new OrganizationIntegrationRequest(
|
||||
OrganizationIntegrationType.Hec,
|
||||
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceType.CrowdStrike}' }`,
|
||||
`{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceName.CrowdStrike}' }`,
|
||||
);
|
||||
const orgId = "org1" as OrganizationId;
|
||||
const integrationId = "integration1" as OrganizationIntegrationId;
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import {
|
||||
OrganizationId,
|
||||
OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import { OrgIntegrationBuilder } from "../models/integration-builder";
|
||||
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
|
||||
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
|
||||
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
|
||||
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
|
||||
import { OrganizationIntegrationServiceName } from "../models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
|
||||
import { OrganizationIntegrationService } from "./organization-integration-service";
|
||||
|
||||
describe("OrganizationIntegrationService", () => {
|
||||
let service: OrganizationIntegrationService;
|
||||
let integrationApiService: MockProxy<OrganizationIntegrationApiService>;
|
||||
let integrationConfigurationApiService: MockProxy<OrganizationIntegrationConfigurationApiService>;
|
||||
|
||||
const orgId = "org-123" as OrganizationId;
|
||||
const integrationId = "integration-456" as OrganizationIntegrationId;
|
||||
const configurationId = "config-789" as OrganizationIntegrationConfigurationId;
|
||||
|
||||
const mockIntegrationResponse = new OrganizationIntegrationResponse({
|
||||
Id: integrationId,
|
||||
Type: OrganizationIntegrationType.Hec,
|
||||
Configuration: JSON.stringify({
|
||||
uri: "https://test.splunk.com",
|
||||
token: "test-token",
|
||||
service: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
}),
|
||||
});
|
||||
|
||||
const mockConfigurationResponse = new OrganizationIntegrationConfigurationResponse({
|
||||
Id: configurationId,
|
||||
Template: JSON.stringify({
|
||||
index: "main",
|
||||
service: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
integrationApiService = mock<OrganizationIntegrationApiService>();
|
||||
integrationConfigurationApiService = mock<OrganizationIntegrationConfigurationApiService>();
|
||||
|
||||
service = new OrganizationIntegrationService(
|
||||
integrationApiService,
|
||||
integrationConfigurationApiService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should be created", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with empty integrations", async () => {
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOrganizationId", () => {
|
||||
it("should fetch and set integrations for the organization", async () => {
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([mockIntegrationResponse]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
|
||||
Promise.resolve([mockConfigurationResponse]),
|
||||
);
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
|
||||
// Wait for the observable to emit
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(1);
|
||||
expect(integrations[0].id).toBe(integrationId);
|
||||
expect(integrations[0].type).toBe(OrganizationIntegrationType.Hec);
|
||||
expect(integrationApiService.getOrganizationIntegrations).toHaveBeenCalledWith(orgId);
|
||||
expect(
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations,
|
||||
).toHaveBeenCalledWith(orgId, integrationId);
|
||||
});
|
||||
|
||||
it("should skip fetching if organization ID is the same", async () => {
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
integrationApiService.getOrganizationIntegrations.mockClear();
|
||||
|
||||
// Call again with the same org ID
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(integrationApiService.getOrganizationIntegrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clear existing integrations when switching organizations", async () => {
|
||||
const orgId2 = "org-456" as OrganizationId;
|
||||
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([mockIntegrationResponse]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
|
||||
Promise.resolve([mockConfigurationResponse]),
|
||||
);
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
let integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(1);
|
||||
|
||||
// Switch to different org
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
|
||||
service.setOrganizationId(orgId2).subscribe();
|
||||
|
||||
// Should immediately clear
|
||||
integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toEqual([]);
|
||||
});
|
||||
|
||||
it("should unsubscribe from previous fetch when setting new organization", async () => {
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const orgId2 = "org-456" as OrganizationId;
|
||||
service.setOrganizationId(orgId2).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should call the API for both organizations (no errors about duplicate subscriptions)
|
||||
// The exact call count may vary based on observable behavior
|
||||
expect(integrationApiService.getOrganizationIntegrations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple integrations", async () => {
|
||||
const integration2Response = new OrganizationIntegrationResponse({
|
||||
Id: "integration-2" as OrganizationIntegrationId,
|
||||
Type: OrganizationIntegrationType.Datadog,
|
||||
Configuration: JSON.stringify({
|
||||
uri: "https://datadog.com",
|
||||
apiKey: "test-api-key",
|
||||
service: OrganizationIntegrationServiceName.Datadog,
|
||||
}),
|
||||
});
|
||||
|
||||
const configuration2Response = new OrganizationIntegrationConfigurationResponse({
|
||||
Id: "config-2" as OrganizationIntegrationConfigurationId,
|
||||
Template: JSON.stringify({
|
||||
service: OrganizationIntegrationServiceName.Datadog,
|
||||
}),
|
||||
});
|
||||
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([mockIntegrationResponse, integration2Response]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations
|
||||
.mockReturnValueOnce(Promise.resolve([mockConfigurationResponse]))
|
||||
.mockReturnValueOnce(Promise.resolve([configuration2Response]));
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
const config = OrgIntegrationBuilder.buildHecConfiguration(
|
||||
"https://test.splunk.com",
|
||||
"test-token",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
const template = OrgIntegrationBuilder.buildHecTemplate(
|
||||
"main",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Set the organization first
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
});
|
||||
|
||||
it("should save a new integration successfully", async () => {
|
||||
integrationApiService.createOrganizationIntegration.mockResolvedValue(
|
||||
mockIntegrationResponse,
|
||||
);
|
||||
integrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
mockConfigurationResponse,
|
||||
);
|
||||
|
||||
const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: false, success: true });
|
||||
expect(integrationApiService.createOrganizationIntegration).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
expect.any(OrganizationIntegrationRequest),
|
||||
);
|
||||
expect(
|
||||
integrationConfigurationApiService.createOrganizationIntegrationConfiguration,
|
||||
).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
integrationId,
|
||||
expect.any(OrganizationIntegrationConfigurationRequest),
|
||||
);
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(1);
|
||||
expect(integrations[0].id).toBe(integrationId);
|
||||
});
|
||||
|
||||
it("should throw error when organization ID mismatch", async () => {
|
||||
const differentOrgId = "different-org" as OrganizationId;
|
||||
|
||||
await expect(
|
||||
service.save(differentOrgId, OrganizationIntegrationType.Hec, config, template),
|
||||
).rejects.toThrow("Organization ID mismatch");
|
||||
});
|
||||
|
||||
it("should return mustBeOwner true when API returns 404", async () => {
|
||||
const error = new ErrorResponse({}, 404);
|
||||
integrationApiService.createOrganizationIntegration.mockRejectedValue(error);
|
||||
|
||||
const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: true, success: false });
|
||||
});
|
||||
|
||||
it("should rethrow non-404 errors", async () => {
|
||||
const error = new Error("Server error");
|
||||
integrationApiService.createOrganizationIntegration.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.save(orgId, OrganizationIntegrationType.Hec, config, template),
|
||||
).rejects.toThrow("Server error");
|
||||
});
|
||||
|
||||
it("should handle configuration creation failure with 404", async () => {
|
||||
const error = new ErrorResponse({}, 404);
|
||||
integrationApiService.createOrganizationIntegration.mockResolvedValue(
|
||||
mockIntegrationResponse,
|
||||
);
|
||||
integrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockRejectedValue(
|
||||
error,
|
||||
);
|
||||
|
||||
const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: true, success: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
const config = OrgIntegrationBuilder.buildHecConfiguration(
|
||||
"https://updated.splunk.com",
|
||||
"updated-token",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
const template = OrgIntegrationBuilder.buildHecTemplate(
|
||||
"updated-index",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Set the organization and add an existing integration
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([mockIntegrationResponse]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
|
||||
Promise.resolve([mockConfigurationResponse]),
|
||||
);
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
});
|
||||
|
||||
it("should update an integration successfully", async () => {
|
||||
const updatedIntegrationResponse = new OrganizationIntegrationResponse({
|
||||
Id: integrationId,
|
||||
Type: OrganizationIntegrationType.Hec,
|
||||
Configuration: JSON.stringify({
|
||||
uri: "https://updated.splunk.com",
|
||||
token: "updated-token",
|
||||
service: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
}),
|
||||
});
|
||||
|
||||
const updatedConfigurationResponse = new OrganizationIntegrationConfigurationResponse({
|
||||
Id: configurationId,
|
||||
Template: JSON.stringify({
|
||||
index: "updated-index",
|
||||
service: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
}),
|
||||
});
|
||||
|
||||
integrationApiService.updateOrganizationIntegration.mockResolvedValue(
|
||||
updatedIntegrationResponse,
|
||||
);
|
||||
integrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
updatedConfigurationResponse,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const result = await service.update(
|
||||
orgId,
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
configurationId,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: false, success: true });
|
||||
expect(integrationApiService.updateOrganizationIntegration).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
integrationId,
|
||||
expect.any(OrganizationIntegrationRequest),
|
||||
);
|
||||
expect(
|
||||
integrationConfigurationApiService.updateOrganizationIntegrationConfiguration,
|
||||
).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
integrationId,
|
||||
configurationId,
|
||||
expect.any(OrganizationIntegrationConfigurationRequest),
|
||||
);
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(1);
|
||||
expect(integrations[0].id).toBe(integrationId);
|
||||
});
|
||||
|
||||
it("should throw error when organization ID mismatch", async () => {
|
||||
const differentOrgId = "different-org" as OrganizationId;
|
||||
|
||||
await expect(
|
||||
service.update(
|
||||
differentOrgId,
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
configurationId,
|
||||
config,
|
||||
template,
|
||||
),
|
||||
).rejects.toThrow("Organization ID mismatch");
|
||||
});
|
||||
|
||||
it("should return mustBeOwner true when API returns 404", async () => {
|
||||
const error = new ErrorResponse({}, 404);
|
||||
integrationApiService.updateOrganizationIntegration.mockRejectedValue(error);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const result = await service.update(
|
||||
orgId,
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
configurationId,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: true, success: false });
|
||||
});
|
||||
|
||||
it("should rethrow non-404 errors", async () => {
|
||||
const error = new Error("Server error");
|
||||
integrationApiService.updateOrganizationIntegration.mockRejectedValue(error);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await expect(
|
||||
service.update(
|
||||
orgId,
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
configurationId,
|
||||
config,
|
||||
template,
|
||||
),
|
||||
).rejects.toThrow("Server error");
|
||||
});
|
||||
|
||||
it("should replace old integration with updated one in the list", async () => {
|
||||
// Add multiple integrations first
|
||||
const integration2Response = new OrganizationIntegrationResponse({
|
||||
Id: "integration-2" as OrganizationIntegrationId,
|
||||
Type: OrganizationIntegrationType.Hec,
|
||||
Configuration: mockIntegrationResponse.configuration,
|
||||
});
|
||||
const configuration2Response = new OrganizationIntegrationConfigurationResponse({
|
||||
Id: "config-2" as OrganizationIntegrationConfigurationId,
|
||||
Template: mockConfigurationResponse.template,
|
||||
});
|
||||
|
||||
const orgId2 = "org-456" as OrganizationId;
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([mockIntegrationResponse, integration2Response]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations
|
||||
.mockReturnValue(Promise.resolve([mockConfigurationResponse]))
|
||||
.mockReturnValueOnce(Promise.resolve([mockConfigurationResponse]))
|
||||
.mockReturnValueOnce(Promise.resolve([configuration2Response]));
|
||||
|
||||
service.setOrganizationId(orgId2).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
let integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(2);
|
||||
|
||||
// Now update the first integration
|
||||
integrationApiService.updateOrganizationIntegration.mockResolvedValue(
|
||||
mockIntegrationResponse,
|
||||
);
|
||||
integrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
mockConfigurationResponse,
|
||||
);
|
||||
|
||||
await service.update(
|
||||
orgId2,
|
||||
integrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
configurationId,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
|
||||
integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(2);
|
||||
expect(integrations.find((i) => i.id === integrationId)).toBeDefined();
|
||||
expect(integrations.find((i) => i.id === "integration-2")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
beforeEach(() => {
|
||||
// Set the organization and add an existing integration
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([mockIntegrationResponse]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
|
||||
Promise.resolve([mockConfigurationResponse]),
|
||||
);
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
});
|
||||
|
||||
it("should delete an integration successfully", async () => {
|
||||
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
integrationApiService.deleteOrganizationIntegration.mockResolvedValue(undefined);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
let integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(1);
|
||||
|
||||
const result = await service.delete(orgId, integrationId, configurationId);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: false, success: true });
|
||||
expect(
|
||||
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration,
|
||||
).toHaveBeenCalledWith(orgId, integrationId, configurationId);
|
||||
expect(integrationApiService.deleteOrganizationIntegration).toHaveBeenCalledWith(
|
||||
orgId,
|
||||
integrationId,
|
||||
);
|
||||
|
||||
integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should delete configuration before integration", async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockImplementation(
|
||||
async () => {
|
||||
callOrder.push("configuration");
|
||||
},
|
||||
);
|
||||
integrationApiService.deleteOrganizationIntegration.mockImplementation(async () => {
|
||||
callOrder.push("integration");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await service.delete(orgId, integrationId, configurationId);
|
||||
|
||||
expect(callOrder).toEqual(["configuration", "integration"]);
|
||||
});
|
||||
|
||||
it("should throw error when organization ID mismatch", async () => {
|
||||
const differentOrgId = "different-org" as OrganizationId;
|
||||
|
||||
await expect(service.delete(differentOrgId, integrationId, configurationId)).rejects.toThrow(
|
||||
"Organization ID mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return mustBeOwner true when API returns 404", async () => {
|
||||
const error = new ErrorResponse({}, 404);
|
||||
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockRejectedValue(
|
||||
error,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const result = await service.delete(orgId, integrationId, configurationId);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: true, success: false });
|
||||
});
|
||||
|
||||
it("should rethrow non-404 errors", async () => {
|
||||
const error = new Error("Server error");
|
||||
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockRejectedValue(
|
||||
error,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await expect(service.delete(orgId, integrationId, configurationId)).rejects.toThrow(
|
||||
"Server error",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle 404 error when deleting integration", async () => {
|
||||
const error = new ErrorResponse({}, 404);
|
||||
integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
integrationApiService.deleteOrganizationIntegration.mockRejectedValue(error);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const result = await service.delete(orgId, integrationId, configurationId);
|
||||
|
||||
expect(result).toEqual({ mustBeOwner: true, success: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapResponsesToOrganizationIntegration", () => {
|
||||
it("should return null if configuration cannot be built", () => {
|
||||
const invalidIntegrationResponse = new OrganizationIntegrationResponse({
|
||||
Id: integrationId,
|
||||
Type: 999 as OrganizationIntegrationType, // Invalid type
|
||||
Configuration: "invalid-json",
|
||||
});
|
||||
|
||||
// The buildConfiguration method throws for unsupported types
|
||||
// In production, this error is caught in the setIntegrations pipeline
|
||||
expect(() =>
|
||||
service["mapResponsesToOrganizationIntegration"](
|
||||
invalidIntegrationResponse,
|
||||
mockConfigurationResponse,
|
||||
),
|
||||
).toThrow("Unsupported integration type: 999");
|
||||
});
|
||||
|
||||
it("should handle template with invalid data", () => {
|
||||
const invalidConfigurationResponse = new OrganizationIntegrationConfigurationResponse({
|
||||
Id: configurationId,
|
||||
Template: "{}", // Empty template, will have undefined values but won't return null
|
||||
});
|
||||
|
||||
const result = service["mapResponsesToOrganizationIntegration"](
|
||||
mockIntegrationResponse,
|
||||
invalidConfigurationResponse,
|
||||
);
|
||||
|
||||
// The result won't be null, but will have a template with undefined/default values
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.integrationConfiguration[0].template).toBeDefined();
|
||||
});
|
||||
|
||||
it("should successfully map valid responses to OrganizationIntegration", () => {
|
||||
const result = service["mapResponsesToOrganizationIntegration"](
|
||||
mockIntegrationResponse,
|
||||
mockConfigurationResponse,
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe(integrationId);
|
||||
expect(result?.type).toBe(OrganizationIntegrationType.Hec);
|
||||
expect(result?.integrationConfiguration).toHaveLength(1);
|
||||
expect(result?.integrationConfiguration[0].id).toBe(configurationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty integration list from API", async () => {
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([]));
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle errors when fetching integrations", async () => {
|
||||
const validIntegration = mockIntegrationResponse;
|
||||
|
||||
integrationApiService.getOrganizationIntegrations.mockReturnValue(
|
||||
Promise.resolve([validIntegration]),
|
||||
);
|
||||
integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue(
|
||||
Promise.resolve([mockConfigurationResponse]),
|
||||
);
|
||||
|
||||
service.setOrganizationId(orgId).subscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const integrations = await firstValueFrom(service.integrations$);
|
||||
expect(integrations).toHaveLength(1);
|
||||
expect(integrations[0].id).toBe(integrationId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { BehaviorSubject, map, Observable, of, switchMap, tap, zip } from "rxjs";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import {
|
||||
OrganizationId,
|
||||
OrganizationIntegrationId,
|
||||
OrganizationIntegrationConfigurationId,
|
||||
} from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
OrgIntegrationBuilder,
|
||||
OrgIntegrationConfiguration,
|
||||
OrgIntegrationTemplate,
|
||||
} from "../models/integration-builder";
|
||||
import { OrganizationIntegration } from "../models/organization-integration";
|
||||
import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration";
|
||||
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
|
||||
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
|
||||
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
|
||||
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
|
||||
import { OrganizationIntegrationType } from "../models/organization-integration-type";
|
||||
|
||||
import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
|
||||
/**
|
||||
* Common result type for integration modification operations (save, update, delete).
|
||||
* was the server side failure due to insufficient permissions (must be owner)?
|
||||
*/
|
||||
export type IntegrationModificationResult = {
|
||||
mustBeOwner: boolean;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides common functionality for managing integrations with different external services.
|
||||
*/
|
||||
export class OrganizationIntegrationService {
|
||||
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
|
||||
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
|
||||
|
||||
integrations$: Observable<OrganizationIntegration[]> = this._integrations$.asObservable();
|
||||
|
||||
constructor(
|
||||
protected integrationApiService: OrganizationIntegrationApiService,
|
||||
protected integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sets the organization Id and triggers the retrieval of integrations for the given organization.
|
||||
* The integrations will be available via the integrations$ observable.
|
||||
* If the organization ID is the same as the current one, no action is taken.
|
||||
* Use this method to kick off loading integrations for a specific organization.
|
||||
* Use integrations$ to subscribe to the loaded integrations.
|
||||
*
|
||||
* @param orgId - The organization ID to set
|
||||
* @returns Observable<void> that completes when the operation is done. Subscribe to trigger the load.
|
||||
*/
|
||||
setOrganizationId(orgId: OrganizationId): Observable<void> {
|
||||
if (orgId === this.organizationId$.getValue()) {
|
||||
return of(void 0);
|
||||
}
|
||||
this._integrations$.next([]);
|
||||
this.organizationId$.next(orgId);
|
||||
|
||||
// subscribe to load and set integrations
|
||||
// use integrations$ to get the loaded integrations
|
||||
return this.setIntegrations(orgId).pipe(
|
||||
tap((integrations) => {
|
||||
this._integrations$.next(integrations);
|
||||
}),
|
||||
map((): void => void 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a new organization integration and updates the integrations$ observable.
|
||||
*
|
||||
* @param organizationId - ID of the organization
|
||||
* @param integrationType - Type of the organization integration
|
||||
* @param config - The configuration object for this integration
|
||||
* @param template - The template object for this integration
|
||||
* @returns Promise with the result indicating success or failure reason
|
||||
*/
|
||||
async save(
|
||||
organizationId: OrganizationId,
|
||||
integrationType: OrganizationIntegrationType,
|
||||
config: OrgIntegrationConfiguration,
|
||||
template: OrgIntegrationTemplate,
|
||||
): Promise<IntegrationModificationResult> {
|
||||
if (organizationId !== this.organizationId$.getValue()) {
|
||||
throw new Error("Organization ID mismatch");
|
||||
}
|
||||
|
||||
try {
|
||||
const configString = config.toString();
|
||||
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
|
||||
organizationId,
|
||||
new OrganizationIntegrationRequest(integrationType, configString),
|
||||
);
|
||||
|
||||
const templateString = template.toString();
|
||||
const newIntegrationConfigResponse =
|
||||
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
newIntegrationResponse.id,
|
||||
new OrganizationIntegrationConfigurationRequest(null, null, null, templateString),
|
||||
);
|
||||
|
||||
const newIntegration = this.mapResponsesToOrganizationIntegration(
|
||||
newIntegrationResponse,
|
||||
newIntegrationConfigResponse,
|
||||
);
|
||||
if (newIntegration !== null) {
|
||||
this._integrations$.next([...this._integrations$.getValue(), newIntegration]);
|
||||
}
|
||||
return { mustBeOwner: false, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
return { mustBeOwner: true, success: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing organization integration and updates the integrations$ observable.
|
||||
*
|
||||
* @param organizationId - ID of the organization
|
||||
* @param integrationId - ID of the organization integration
|
||||
* @param integrationType - Type of the organization integration
|
||||
* @param configurationId - ID of the organization integration configuration
|
||||
* @param config - The updated configuration object
|
||||
* @param template - The updated template object
|
||||
* @returns Promise with the result indicating success or failure reason
|
||||
*/
|
||||
async update(
|
||||
organizationId: OrganizationId,
|
||||
integrationId: OrganizationIntegrationId,
|
||||
integrationType: OrganizationIntegrationType,
|
||||
configurationId: OrganizationIntegrationConfigurationId,
|
||||
config: OrgIntegrationConfiguration,
|
||||
template: OrgIntegrationTemplate,
|
||||
): Promise<IntegrationModificationResult> {
|
||||
if (organizationId !== this.organizationId$.getValue()) {
|
||||
throw new Error("Organization ID mismatch");
|
||||
}
|
||||
|
||||
try {
|
||||
const configString = config.toString();
|
||||
const updatedIntegrationResponse =
|
||||
await this.integrationApiService.updateOrganizationIntegration(
|
||||
organizationId,
|
||||
integrationId,
|
||||
new OrganizationIntegrationRequest(integrationType, configString),
|
||||
);
|
||||
|
||||
const templateString = template.toString();
|
||||
const updatedIntegrationConfigResponse =
|
||||
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
integrationId,
|
||||
configurationId,
|
||||
new OrganizationIntegrationConfigurationRequest(null, null, null, templateString),
|
||||
);
|
||||
|
||||
const updatedIntegration = this.mapResponsesToOrganizationIntegration(
|
||||
updatedIntegrationResponse,
|
||||
updatedIntegrationConfigResponse,
|
||||
);
|
||||
|
||||
if (updatedIntegration !== null) {
|
||||
const integrations = this._integrations$.getValue();
|
||||
const index = integrations.findIndex((i) => i.id === integrationId);
|
||||
if (index !== -1) {
|
||||
integrations[index] = updatedIntegration;
|
||||
} else {
|
||||
integrations.push(updatedIntegration);
|
||||
}
|
||||
this._integrations$.next([...integrations]);
|
||||
}
|
||||
return { mustBeOwner: false, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
return { mustBeOwner: true, success: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an organization integration and updates the integrations$ observable.
|
||||
*
|
||||
* @param organizationId - ID of the organization
|
||||
* @param integrationId - ID of the organization integration
|
||||
* @param configurationId - ID of the organization integration configuration
|
||||
* @returns Promise with the result indicating success or failure reason
|
||||
*/
|
||||
async delete(
|
||||
organizationId: OrganizationId,
|
||||
integrationId: OrganizationIntegrationId,
|
||||
configurationId: OrganizationIntegrationConfigurationId,
|
||||
): Promise<IntegrationModificationResult> {
|
||||
if (organizationId !== this.organizationId$.getValue()) {
|
||||
throw new Error("Organization ID mismatch");
|
||||
}
|
||||
|
||||
try {
|
||||
// delete the configuration first due to foreign key constraint
|
||||
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
|
||||
organizationId,
|
||||
integrationId,
|
||||
configurationId,
|
||||
);
|
||||
|
||||
// delete the integration
|
||||
await this.integrationApiService.deleteOrganizationIntegration(organizationId, integrationId);
|
||||
|
||||
// update the local observable
|
||||
const updatedIntegrations = this._integrations$
|
||||
.getValue()
|
||||
.filter((i) => i.id !== integrationId);
|
||||
this._integrations$.next(updatedIntegrations);
|
||||
|
||||
return { mustBeOwner: false, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === 404) {
|
||||
return { mustBeOwner: true, success: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps API responses to an OrganizationIntegration domain model.
|
||||
*
|
||||
* @param integrationResponse - The integration response from the API
|
||||
* @param configurationResponse - The configuration response from the API
|
||||
* @returns OrganizationIntegration or null if mapping fails
|
||||
*/
|
||||
private mapResponsesToOrganizationIntegration(
|
||||
integrationResponse: OrganizationIntegrationResponse,
|
||||
configurationResponse: OrganizationIntegrationConfigurationResponse,
|
||||
): OrganizationIntegration | null {
|
||||
const integrationType = integrationResponse.type;
|
||||
const config = OrgIntegrationBuilder.buildConfiguration(
|
||||
integrationType,
|
||||
integrationResponse.configuration,
|
||||
);
|
||||
const template = OrgIntegrationBuilder.buildTemplate(
|
||||
integrationType,
|
||||
configurationResponse.template ?? "{}",
|
||||
);
|
||||
|
||||
if (!config || !template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const integrationConfig = new OrganizationIntegrationConfiguration(
|
||||
configurationResponse.id,
|
||||
integrationResponse.id,
|
||||
null,
|
||||
"",
|
||||
template,
|
||||
);
|
||||
|
||||
return new OrganizationIntegration(
|
||||
integrationResponse.id,
|
||||
integrationResponse.type,
|
||||
config.service,
|
||||
config,
|
||||
[integrationConfig],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches integrations for the given organization from the API.
|
||||
*
|
||||
* @param orgId - Organization ID to fetch integrations for
|
||||
* @returns Observable of OrganizationIntegration array
|
||||
*/
|
||||
private setIntegrations(orgId: OrganizationId): Observable<OrganizationIntegration[]> {
|
||||
const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe(
|
||||
switchMap(([responses]) => {
|
||||
const integrations: OrganizationIntegration[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
responses.forEach((integration) => {
|
||||
const promise = this.integrationConfigurationApiService
|
||||
.getOrganizationIntegrationConfigurations(orgId, integration.id)
|
||||
.then((response) => {
|
||||
// Integration will only have one OrganizationIntegrationConfiguration
|
||||
const config = response[0];
|
||||
|
||||
const orgIntegration = this.mapResponsesToOrganizationIntegration(
|
||||
integration,
|
||||
config,
|
||||
);
|
||||
|
||||
if (orgIntegration !== null) {
|
||||
integrations.push(orgIntegration);
|
||||
}
|
||||
});
|
||||
promises.push(promise);
|
||||
});
|
||||
return Promise.all(promises).then(() => {
|
||||
return integrations;
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return results$;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
@@ -29,8 +30,7 @@ describe("IntegrationCardComponent", () => {
|
||||
let fixture: ComponentFixture<IntegrationCardComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const activatedRoute = mock<ActivatedRoute>();
|
||||
const mockIntegrationService = mock<HecOrganizationIntegrationService>();
|
||||
const mockDatadogIntegrationService = mock<DatadogOrganizationIntegrationService>();
|
||||
const mockIntegrationService = mock<OrganizationIntegrationService>();
|
||||
const dialogService = mock<DialogService>();
|
||||
const toastService = mock<ToastService>();
|
||||
|
||||
@@ -54,8 +54,7 @@ describe("IntegrationCardComponent", () => {
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: HecOrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{ provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService },
|
||||
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
@@ -259,7 +258,7 @@ describe("IntegrationCardComponent", () => {
|
||||
configuration: {},
|
||||
integrationConfiguration: [{ id: "config-id" }],
|
||||
},
|
||||
name: OrganizationIntegrationServiceType.CrowdStrike,
|
||||
name: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
} as any;
|
||||
component.organizationId = "org-id" as any;
|
||||
jest.resetAllMocks();
|
||||
@@ -270,8 +269,8 @@ describe("IntegrationCardComponent", () => {
|
||||
closed: of({ success: false }),
|
||||
});
|
||||
await component.setupConnection();
|
||||
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.update).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call updateHec if isUpdateAvailable is true", async () => {
|
||||
@@ -284,26 +283,35 @@ describe("IntegrationCardComponent", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const config = OrgIntegrationBuilder.buildHecConfiguration(
|
||||
"test-url",
|
||||
"token",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
const template = OrgIntegrationBuilder.buildHecTemplate(
|
||||
"index",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.updateHec).toHaveBeenCalledWith(
|
||||
expect(mockIntegrationService.update).toHaveBeenCalledWith(
|
||||
"org-id",
|
||||
"integration-id",
|
||||
OrganizationIntegrationType.Hec,
|
||||
"config-id",
|
||||
OrganizationIntegrationServiceType.CrowdStrike,
|
||||
"test-url",
|
||||
"token",
|
||||
"index",
|
||||
config,
|
||||
template,
|
||||
);
|
||||
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call saveHec if isUpdateAvailable is false", async () => {
|
||||
component.integrationSettings = {
|
||||
organizationIntegration: null,
|
||||
name: OrganizationIntegrationServiceType.CrowdStrike,
|
||||
name: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
} as any;
|
||||
component.organizationId = "org-id" as any;
|
||||
|
||||
@@ -316,23 +324,32 @@ describe("IntegrationCardComponent", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const config = OrgIntegrationBuilder.buildHecConfiguration(
|
||||
"test-url",
|
||||
"token",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
const template = OrgIntegrationBuilder.buildHecTemplate(
|
||||
"index",
|
||||
OrganizationIntegrationServiceName.CrowdStrike,
|
||||
);
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false);
|
||||
|
||||
mockIntegrationService.saveHec.mockResolvedValue({ mustBeOwner: false, success: true });
|
||||
mockIntegrationService.save.mockResolvedValue({ mustBeOwner: false, success: true });
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.saveHec).toHaveBeenCalledWith(
|
||||
expect(mockIntegrationService.save).toHaveBeenCalledWith(
|
||||
"org-id",
|
||||
OrganizationIntegrationServiceType.CrowdStrike,
|
||||
"test-url",
|
||||
"token",
|
||||
"index",
|
||||
OrganizationIntegrationType.Hec,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call deleteHec when a delete is requested", async () => {
|
||||
it("should call delete with Hec type when a delete is requested", async () => {
|
||||
component.organizationId = "org-id" as any;
|
||||
|
||||
(openHecConnectDialog as jest.Mock).mockReturnValue({
|
||||
@@ -344,22 +361,22 @@ describe("IntegrationCardComponent", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true });
|
||||
mockIntegrationService.delete.mockResolvedValue({ mustBeOwner: false, success: true });
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.deleteHec).toHaveBeenCalledWith(
|
||||
expect(mockIntegrationService.delete).toHaveBeenCalledWith(
|
||||
"org-id",
|
||||
"integration-id",
|
||||
"config-id",
|
||||
);
|
||||
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not call deleteHec if no existing configuration", async () => {
|
||||
it("should not call delete if no existing configuration", async () => {
|
||||
component.integrationSettings = {
|
||||
organizationIntegration: null,
|
||||
name: OrganizationIntegrationServiceType.CrowdStrike,
|
||||
name: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
} as any;
|
||||
component.organizationId = "org-id" as any;
|
||||
|
||||
@@ -372,20 +389,16 @@ describe("IntegrationCardComponent", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true });
|
||||
mockIntegrationService.delete.mockResolvedValue({ mustBeOwner: false, success: true });
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.deleteHec).not.toHaveBeenCalledWith(
|
||||
expect(mockIntegrationService.delete).not.toHaveBeenCalledWith(
|
||||
"org-id",
|
||||
"integration-id",
|
||||
"config-id",
|
||||
OrganizationIntegrationServiceType.CrowdStrike,
|
||||
"test-url",
|
||||
"token",
|
||||
"index",
|
||||
);
|
||||
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
|
||||
expect(mockIntegrationService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show toast on error while saving", async () => {
|
||||
@@ -399,11 +412,11 @@ describe("IntegrationCardComponent", () => {
|
||||
});
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
|
||||
mockIntegrationService.updateHec.mockRejectedValue(new Error("fail"));
|
||||
mockIntegrationService.update.mockRejectedValue(new Error("fail"));
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
|
||||
expect(mockIntegrationService.update).toHaveBeenCalled();
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
@@ -422,11 +435,11 @@ describe("IntegrationCardComponent", () => {
|
||||
});
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
|
||||
mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
|
||||
mockIntegrationService.update.mockRejectedValue(new ErrorResponse("Not Found", 404));
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
|
||||
expect(mockIntegrationService.update).toHaveBeenCalled();
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
@@ -445,11 +458,10 @@ describe("IntegrationCardComponent", () => {
|
||||
});
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
|
||||
mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
|
||||
|
||||
mockIntegrationService.update.mockRejectedValue(new ErrorResponse("Not Found", 404));
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
|
||||
expect(mockIntegrationService.update).toHaveBeenCalled();
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
@@ -468,11 +480,11 @@ describe("IntegrationCardComponent", () => {
|
||||
});
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
|
||||
mockIntegrationService.deleteHec.mockRejectedValue(new Error("fail"));
|
||||
mockIntegrationService.delete.mockRejectedValue(new Error("fail"));
|
||||
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
|
||||
expect(mockIntegrationService.delete).toHaveBeenCalled();
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
@@ -491,11 +503,10 @@ describe("IntegrationCardComponent", () => {
|
||||
});
|
||||
|
||||
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
|
||||
mockIntegrationService.deleteHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
|
||||
|
||||
mockIntegrationService.delete.mockRejectedValue(new ErrorResponse("Not Found", 404));
|
||||
await component.setupConnection();
|
||||
|
||||
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
|
||||
expect(mockIntegrationService.delete).toHaveBeenCalled();
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
|
||||
@@ -12,10 +12,10 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx
|
||||
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
|
||||
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
@@ -96,8 +96,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
private systemTheme$: Observable<ThemeType>,
|
||||
private dialogService: DialogService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
|
||||
private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService,
|
||||
private organizationIntegrationService: OrganizationIntegrationService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
@@ -250,7 +249,18 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
async saveHec(result: HecConnectDialogResult) {
|
||||
let saveResponse = { mustBeOwner: false, success: false };
|
||||
let response = { mustBeOwner: false, success: false };
|
||||
|
||||
const config = OrgIntegrationBuilder.buildHecConfiguration(
|
||||
result.url,
|
||||
result.bearerToken,
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceName,
|
||||
);
|
||||
const template = OrgIntegrationBuilder.buildHecTemplate(
|
||||
result.index,
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceName,
|
||||
);
|
||||
|
||||
if (this.isUpdateAvailable) {
|
||||
// retrieve org integration and configuration ids
|
||||
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
|
||||
@@ -262,27 +272,25 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
// update existing integration and configuration
|
||||
saveResponse = await this.hecOrganizationIntegrationService.updateHec(
|
||||
response = await this.organizationIntegrationService.update(
|
||||
this.organizationId,
|
||||
orgIntegrationId,
|
||||
OrganizationIntegrationType.Hec,
|
||||
orgIntegrationConfigurationId,
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceType,
|
||||
result.url,
|
||||
result.bearerToken,
|
||||
result.index,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
} else {
|
||||
// create new integration and configuration
|
||||
saveResponse = await this.hecOrganizationIntegrationService.saveHec(
|
||||
response = await this.organizationIntegrationService.save(
|
||||
this.organizationId,
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceType,
|
||||
result.url,
|
||||
result.bearerToken,
|
||||
result.index,
|
||||
OrganizationIntegrationType.Hec,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
}
|
||||
|
||||
if (saveResponse.mustBeOwner) {
|
||||
if (response.mustBeOwner) {
|
||||
this.showMustBeOwnerToast();
|
||||
return;
|
||||
}
|
||||
@@ -303,7 +311,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
throw Error("Organization Integration ID or Configuration ID is missing");
|
||||
}
|
||||
|
||||
const response = await this.hecOrganizationIntegrationService.deleteHec(
|
||||
const response = await this.organizationIntegrationService.delete(
|
||||
this.organizationId,
|
||||
orgIntegrationId,
|
||||
orgIntegrationConfigurationId,
|
||||
@@ -322,6 +330,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
async saveDatadog(result: DatadogConnectDialogResult) {
|
||||
let response = { mustBeOwner: false, success: false };
|
||||
|
||||
const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey);
|
||||
const template = OrgIntegrationBuilder.buildDataDogTemplate(
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceName,
|
||||
);
|
||||
|
||||
if (this.isUpdateAvailable) {
|
||||
// retrieve org integration and configuration ids
|
||||
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
|
||||
@@ -333,23 +348,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
// update existing integration and configuration
|
||||
await this.datadogOrganizationIntegrationService.updateDatadog(
|
||||
response = await this.organizationIntegrationService.update(
|
||||
this.organizationId,
|
||||
orgIntegrationId,
|
||||
OrganizationIntegrationType.Datadog,
|
||||
orgIntegrationConfigurationId,
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceType,
|
||||
result.url,
|
||||
result.apiKey,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
} else {
|
||||
// create new integration and configuration
|
||||
await this.datadogOrganizationIntegrationService.saveDatadog(
|
||||
response = await this.organizationIntegrationService.save(
|
||||
this.organizationId,
|
||||
this.integrationSettings.name as OrganizationIntegrationServiceType,
|
||||
result.url,
|
||||
result.apiKey,
|
||||
OrganizationIntegrationType.Datadog,
|
||||
config,
|
||||
template,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.mustBeOwner) {
|
||||
this.showMustBeOwnerToast();
|
||||
return;
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
@@ -366,7 +387,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
|
||||
throw Error("Organization Integration ID or Configuration ID is missing");
|
||||
}
|
||||
|
||||
const response = await this.datadogOrganizationIntegrationService.deleteDatadog(
|
||||
const response = await this.organizationIntegrationService.delete(
|
||||
this.organizationId,
|
||||
orgIntegrationId,
|
||||
orgIntegrationConfigurationId,
|
||||
|
||||
@@ -6,8 +6,7 @@ import { of } from "rxjs";
|
||||
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
@@ -24,8 +23,7 @@ describe("IntegrationGridComponent", () => {
|
||||
let component: IntegrationGridComponent;
|
||||
let fixture: ComponentFixture<IntegrationGridComponent>;
|
||||
const mockActivatedRoute = mock<ActivatedRoute>();
|
||||
const mockIntegrationService = mock<HecOrganizationIntegrationService>();
|
||||
const mockDatadogIntegrationService = mock<DatadogOrganizationIntegrationService>();
|
||||
const mockIntegrationService = mock<OrganizationIntegrationService>();
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
name: "Integration 1",
|
||||
@@ -71,8 +69,7 @@ describe("IntegrationGridComponent", () => {
|
||||
provide: ActivatedRoute,
|
||||
useValue: mockActivatedRoute,
|
||||
},
|
||||
{ provide: HecOrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{ provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService },
|
||||
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: mock<ToastService>(),
|
||||
|
||||
@@ -1,69 +1,78 @@
|
||||
<app-header> </app-header>
|
||||
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex" *ngIf="organization$ | async as organization">
|
||||
<bit-tab [label]="'singleSignOn' | i18n" *ngIf="organization.useSso">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "ssoDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{ "singleSignOn" | i18n }}</a>
|
||||
{{ "ssoDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
@let organization = organization$ | async;
|
||||
|
||||
<bit-tab
|
||||
[label]="'userProvisioning' | i18n"
|
||||
*ngIf="organization.useScim || organization.useDirectory"
|
||||
>
|
||||
<section class="tw-mb-9" *ngIf="organization.useScim">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
<section class="tw-mb-9" *ngIf="organization.useDirectory">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
@if (organization) {
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex">
|
||||
@if (organization?.useSso) {
|
||||
<bit-tab [label]="'singleSignOn' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "ssoDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{
|
||||
"singleSignOn" | i18n
|
||||
}}</a>
|
||||
{{ "ssoDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
<bit-tab [label]="'eventManagement' | i18n" *ngIf="organization.useEvents">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "eventManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
@if (organization?.useScim || organization?.useDirectory) {
|
||||
<bit-tab [label]="'userProvisioning' | i18n">
|
||||
<section class="tw-mb-9" *ngIf="organization?.useScim">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
<section class="tw-mb-9" *ngIf="organization?.useDirectory">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
<bit-tab [label]="'deviceManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "deviceManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
@if (organization?.useEvents) {
|
||||
<bit-tab [label]="'eventManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "eventManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
<bit-tab [label]="'deviceManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "deviceManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -21,6 +20,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "./integrations.pipe";
|
||||
|
||||
// attempted, but because bit-tab-group is not OnPush, caused more issues than it solved
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -236,10 +236,12 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
// Sets the organization ID which also loads the integrations$
|
||||
this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
|
||||
this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id);
|
||||
this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id);
|
||||
});
|
||||
this.organization$
|
||||
.pipe(
|
||||
switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -247,8 +249,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
|
||||
private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService,
|
||||
private organizationIntegrationService: OrganizationIntegrationService,
|
||||
) {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike)
|
||||
@@ -260,7 +261,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
// Add the new event based items to the list
|
||||
if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
|
||||
const crowdstrikeIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceType.CrowdStrike,
|
||||
name: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
@@ -272,7 +273,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
this.integrationsList.push(crowdstrikeIntegration);
|
||||
|
||||
const datadogIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceType.Datadog,
|
||||
name: OrganizationIntegrationServiceName.Datadog,
|
||||
linkURL: "https://bitwarden.com/help/datadog-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-datadog-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
@@ -286,42 +287,23 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// For all existing event based configurations loop through and assign the
|
||||
// organizationIntegration for the correct services.
|
||||
this.hecOrganizationIntegrationService.integrations$
|
||||
this.organizationIntegrationService.integrations$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((integrations) => {
|
||||
// reset all integrations to null first - in case one was deleted
|
||||
// reset all event based integrations to null first - in case one was deleted
|
||||
this.integrationsList.forEach((i) => {
|
||||
if (i.integrationType === OrganizationIntegrationType.Hec) {
|
||||
i.organizationIntegration = null;
|
||||
}
|
||||
i.organizationIntegration = null;
|
||||
});
|
||||
|
||||
integrations.map((integration) => {
|
||||
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
|
||||
if (item) {
|
||||
item.organizationIntegration = integration;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.datadogOrganizationIntegrationService.integrations$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((integrations) => {
|
||||
// reset all integrations to null first - in case one was deleted
|
||||
this.integrationsList.forEach((i) => {
|
||||
if (i.integrationType === OrganizationIntegrationType.Datadog) {
|
||||
i.organizationIntegration = null;
|
||||
}
|
||||
});
|
||||
|
||||
integrations.map((integration) => {
|
||||
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
|
||||
integrations.forEach((integration) => {
|
||||
const item = this.integrationsList.find((i) => i.name === integration.serviceName);
|
||||
if (item) {
|
||||
item.organizationIntegration = integration;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -14,13 +13,8 @@ import { OrganizationIntegrationsRoutingModule } from "./organization-integratio
|
||||
imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: DatadogOrganizationIntegrationService,
|
||||
useClass: DatadogOrganizationIntegrationService,
|
||||
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: HecOrganizationIntegrationService,
|
||||
useClass: HecOrganizationIntegrationService,
|
||||
provide: OrganizationIntegrationService,
|
||||
useClass: OrganizationIntegrationService,
|
||||
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -9,8 +9,7 @@ import {} from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
@@ -41,8 +40,7 @@ class MockNewMenuComponent {}
|
||||
|
||||
describe("IntegrationsComponent", () => {
|
||||
let fixture: ComponentFixture<IntegrationsComponent>;
|
||||
const hecOrgIntegrationSvc = mock<HecOrganizationIntegrationService>();
|
||||
const datadogOrgIntegrationSvc = mock<DatadogOrganizationIntegrationService>();
|
||||
const orgIntegrationSvc = mock<OrganizationIntegrationService>();
|
||||
|
||||
const activatedRouteMock = {
|
||||
snapshot: { paramMap: { get: jest.fn() } },
|
||||
@@ -60,8 +58,7 @@ describe("IntegrationsComponent", () => {
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteMock },
|
||||
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc },
|
||||
{ provide: DatadogOrganizationIntegrationService, useValue: datadogOrgIntegrationSvc },
|
||||
{ provide: OrganizationIntegrationService, useValue: orgIntegrationSvc },
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(IntegrationsComponent);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service";
|
||||
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
|
||||
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service";
|
||||
import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -23,13 +22,8 @@ import { IntegrationsComponent } from "./integrations.component";
|
||||
],
|
||||
providers: [
|
||||
safeProvider({
|
||||
provide: DatadogOrganizationIntegrationService,
|
||||
useClass: DatadogOrganizationIntegrationService,
|
||||
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: HecOrganizationIntegrationService,
|
||||
useClass: HecOrganizationIntegrationService,
|
||||
provide: OrganizationIntegrationService,
|
||||
useClass: OrganizationIntegrationService,
|
||||
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
Reference in New Issue
Block a user