1
0
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:
Vijay Oommen
2025-12-15 10:30:22 -06:00
committed by GitHub
parent 721f253ef9
commit 3d06668497
25 changed files with 1305 additions and 1342 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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");
}
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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];

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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;
}
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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$;
}
}

View File

@@ -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: "",

View File

@@ -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,

View File

@@ -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>(),

View File

@@ -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>
}

View File

@@ -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();

View File

@@ -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({

View File

@@ -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);

View File

@@ -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({