1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-25 20:53:22 +00:00

[PM-23824] Implement HEC integration (#16274)

This commit is contained in:
Vijay Oommen
2025-09-11 08:10:42 -05:00
committed by GitHub
parent afe3cbd78f
commit 4857855c11
45 changed files with 1067 additions and 228 deletions

View File

@@ -1,6 +0,0 @@
export * from "./services";
export * from "./models/organization-integration-type";
export * from "./models/organization-integration-request";
export * from "./models/organization-integration-response";
export * from "./models/organization-integration-configuration-request";
export * from "./models/organization-integration-configuration-response";

View File

@@ -1,2 +0,0 @@
export * from "./organization-integration-api.service";
export * from "./organization-integration-configuration-api.service";

View File

@@ -0,0 +1,18 @@
import { OrganizationIntegrationServiceType } from "../organization-integration-service-type";
export class HecConfiguration {
uri: string;
scheme = "Bearer";
token: string;
service: OrganizationIntegrationServiceType;
constructor(uri: string, token: string, service: string) {
this.uri = uri;
this.token = token;
this.service = service as OrganizationIntegrationServiceType;
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@@ -0,0 +1,14 @@
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
export class WebhookConfiguration {
propA: string;
propB: string;
constructor(propA: string, propB: string) {
this.propA = propA;
this.propB = propB;
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@@ -0,0 +1,17 @@
import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type";
export class HecTemplate {
event = "#EventMessage#";
source = "Bitwarden";
index: string;
service: OrganizationIntegrationServiceType;
constructor(index: string, service: string) {
this.index = index;
this.service = service as OrganizationIntegrationServiceType;
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@@ -0,0 +1,14 @@
// Added to reflect how future webhook integrations could be structured within the OrganizationIntegration
export class WebhookTemplate {
propA: string;
propB: string;
constructor(propA: string, propB: string) {
this.propA = propA;
this.propB = propB;
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@@ -0,0 +1,13 @@
export class WebhookIntegrationConfigurationConfig {
propA: string;
propB: string;
constructor(propA: string, propB: string) {
this.propA = propA;
this.propB = propB;
}
toString(): string {
return JSON.stringify(this);
}
}

View File

@@ -0,0 +1,30 @@
import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
import { OrganizationIntegration } from "./organization-integration";
/** Integration or SDK */
export type Integration = {
name: string;
image: string;
/**
* Optional image shown in dark mode.
*/
imageDarkMode?: string;
linkURL: string;
type: IntegrationType;
/**
* Shows the "New" badge until the defined date.
* When omitted, the badge is never shown.
*
* @example "2024-12-31"
*/
newBadgeExpiration?: string;
description?: string;
isConnected?: boolean;
canSetupConnection?: boolean;
configuration?: string;
template?: string;
// OrganizationIntegration
organizationIntegration?: OrganizationIntegration | null;
};

View File

@@ -1,6 +1,9 @@
import { EventType } from "@bitwarden/common/enums";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { OrganizationIntegrationConfigurationId } from "@bitwarden/common/types/guid";
import {
OrganizationIntegrationConfigurationId,
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
export class OrganizationIntegrationConfigurationResponse extends BaseResponse {
id: OrganizationIntegrationConfigurationId;
@@ -18,3 +21,16 @@ export class OrganizationIntegrationConfigurationResponse extends BaseResponse {
this.template = this.getResponseProperty("Template");
}
}
export class OrganizationIntegrationConfigurationResponseWithIntegrationId {
integrationId: OrganizationIntegrationId;
configurationResponses: OrganizationIntegrationConfigurationResponse[];
constructor(
integrationId: OrganizationIntegrationId,
configurationResponses: OrganizationIntegrationConfigurationResponse[],
) {
this.integrationId = integrationId;
this.configurationResponses = configurationResponses;
}
}

View File

@@ -0,0 +1,41 @@
import { EventType } from "@bitwarden/common/enums";
import {
OrganizationIntegrationConfigurationId,
OrganizationIntegrationId,
} from "@bitwarden/common/types/guid";
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";
export class OrganizationIntegrationConfiguration {
id: OrganizationIntegrationConfigurationId;
integrationId: OrganizationIntegrationId;
eventType?: EventType | null;
configuration?: WebhookIntegrationConfigurationConfig | null;
filters?: string;
template?: HecTemplate | WebhookTemplate | null;
constructor(
id: OrganizationIntegrationConfigurationId,
integrationId: OrganizationIntegrationId,
eventType?: EventType | null,
configuration?: WebhookIntegrationConfigurationConfig | null,
filters?: string,
template?: HecTemplate | WebhookTemplate | null,
) {
this.id = id;
this.integrationId = integrationId;
this.eventType = eventType;
this.configuration = configuration;
this.filters = filters;
this.template = template;
}
getTemplate<T>(): T | null {
if (this.template && typeof this.template === "object") {
return this.template as T;
}
return null;
}
}

View File

@@ -0,0 +1,36 @@
import { OrganizationIntegrationId } from "@bitwarden/common/types/guid";
import { HecConfiguration } from "./configuration/hec-configuration";
import { WebhookConfiguration } from "./configuration/webhook-configuration";
import { OrganizationIntegrationConfiguration } from "./organization-integration-configuration";
import { OrganizationIntegrationServiceType } from "./organization-integration-service-type";
import { OrganizationIntegrationType } from "./organization-integration-type";
export class OrganizationIntegration {
id: OrganizationIntegrationId;
type: OrganizationIntegrationType;
serviceType: OrganizationIntegrationServiceType;
configuration: HecConfiguration | WebhookConfiguration | null;
integrationConfiguration: OrganizationIntegrationConfiguration[] = [];
constructor(
id: OrganizationIntegrationId,
type: OrganizationIntegrationType,
serviceType: OrganizationIntegrationServiceType,
configuration: HecConfiguration | WebhookConfiguration | null,
integrationConfiguration: OrganizationIntegrationConfiguration[] = [],
) {
this.id = id;
this.type = type;
this.serviceType = serviceType;
this.configuration = configuration;
this.integrationConfiguration = integrationConfiguration;
}
getConfiguration<T>(): T | null {
if (this.configuration && typeof this.configuration === "object") {
return this.configuration as T;
}
return null;
}
}

View File

@@ -0,0 +1,201 @@
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

@@ -0,0 +1,284 @@
import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs";
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 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 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 bearerToken api token
* @param index index in service
*/
async saveHec(
organizationId: OrganizationId,
service: OrganizationIntegrationServiceType,
url: string,
bearerToken: string,
index: string,
) {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
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]);
}
}
/**
* 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,
) {
if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch");
}
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) {
this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]);
}
}
/**
* 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) => {
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

@@ -1,12 +1,9 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
@Injectable()
export class OrganizationIntegrationApiService {
constructor(private apiService: ApiService) {}

View File

@@ -1,5 +1,3 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
OrganizationId,
@@ -10,7 +8,6 @@ import {
import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request";
import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response";
@Injectable()
export class OrganizationIntegrationConfigurationApiService {
constructor(private apiService: ApiService) {}