1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 19:23:19 +00:00

PM-23824 saving and retrieve data from two tables

This commit is contained in:
voommen-livefront
2025-08-05 15:45:34 -05:00
parent 7523d1c78a
commit da927fe366
13 changed files with 795 additions and 79 deletions

View File

@@ -2,10 +2,10 @@
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs";
import { Observable, Subject, switchMap, takeUntil, combineLatest } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
import { HecConfiguration } from "@bitwarden/bit-common/dirt/integrations";
import {
getOrganizationById,
OrganizationService,
@@ -22,6 +22,7 @@ import { SharedOrganizationModule } from "../shared";
import { IntegrationGridComponent } from "../shared/components/integrations/integration-grid/integration-grid.component";
import { FilterIntegrationsPipe } from "../shared/components/integrations/integrations.pipe";
import { Integration } from "../shared/components/integrations/models";
import { OrganizationIntegrationService } from "../shared/components/integrations/services/organization-integration.service";
@Component({
selector: "ac-integrations",
@@ -218,9 +219,11 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
},
];
ngOnInit(): void {
async ngOnInit(): Promise<void> {
const orgId = this.route.snapshot.params.organizationId;
await this.organizationIntegrationService.getIntegrationsAndConfigurations(orgId);
this.organization$ = this.route.params.pipe(
switchMap((params) =>
this.accountService.activeAccount$.pipe(
@@ -233,23 +236,41 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
),
);
scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler)
combineLatest([
this.organizationIntegrationService.integrations$,
this.organizationIntegrationService.integrationConfigurations$,
])
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
.subscribe(([integrations, integrationConfigurations]) => {
const existingIntegrations = [...this.integrationsList];
// Update the integrations list with the fetched integrations
if (integrations && integrations.length > 0) {
integrations.forEach((integration) => {
const configJson = JSON.parse(integration.configuration || "{}");
const serviceName = configJson.service ?? "";
const existingIntegration = this.integrationsList.find((i) => i.name === serviceName);
const configJson = this.organizationIntegrationService.convertToJson<HecConfiguration>(
integration.configuration,
);
const serviceName = configJson?.service ?? "";
const existingIntegration = existingIntegrations.find((i) => i.name === serviceName);
if (existingIntegration) {
// if a configuration exists, then it is connected
// update integrations
existingIntegration.isConnected = !!integration.configuration;
existingIntegration.configuration = integration.configuration || "";
const template = this.organizationIntegrationService.getIntegrationConfiguration(
integration.id,
serviceName,
integrationConfigurations,
);
existingIntegration.template = JSON.stringify(template || {});
}
});
}
// update the integrations list
this.integrationsList = existingIntegrations;
});
}
@@ -258,7 +279,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
private orgIntegrationApiService: OrganizationIntegrationApiService,
private organizationIntegrationService: OrganizationIntegrationService,
) {
this.configService
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
@@ -284,6 +305,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
// use in the view
get IntegrationType(): typeof IntegrationType {
return IntegrationType;
}

View File

@@ -2,3 +2,4 @@ export * from "./integrations.pipe";
export * from "./integration-card/integration-card.component";
export * from "./integration-grid/integration-grid.component";
export * from "./models";
export * from "./services/organization-integration.service";

View File

@@ -4,8 +4,6 @@ import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
@@ -14,6 +12,8 @@ import { ToastService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common";
import { OrganizationIntegrationService } from "../services/organization-integration.service";
import { IntegrationCardComponent } from "./integration-card.component";
describe("IntegrationCardComponent", () => {
@@ -21,7 +21,7 @@ describe("IntegrationCardComponent", () => {
let fixture: ComponentFixture<IntegrationCardComponent>;
const mockI18nService = mock<I18nService>();
const activatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const mockIntegrationService = mock<OrganizationIntegrationService>();
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
@@ -43,7 +43,7 @@ describe("IntegrationCardComponent", () => {
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
{ provide: ToastService, useValue: mock<ToastService>() },
],
}).compileComponents();

View File

@@ -15,11 +15,9 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import {
OrganizationIntegrationType,
OrganizationIntegrationRequest,
OrganizationIntegrationResponse,
OrganizationIntegrationApiService,
} from "@bitwarden/bit-common/dirt/integrations/index";
HecConfiguration,
HecConfigurationTemplate,
} from "@bitwarden/bit-common/dirt/integrations";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
@@ -29,6 +27,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../../../../shared/shared.module";
import { openHecConnectDialog } from "../integration-dialog/index";
import { Integration } from "../models";
import { OrganizationIntegrationService } from "../services/organization-integration.service";
@Component({
selector: "app-integration-card",
@@ -59,18 +58,24 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
@Input() isConnected?: boolean;
@Input() canSetupConnection?: boolean;
organizationId: OrganizationId;
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
private dialogService: DialogService,
private activatedRoute: ActivatedRoute,
private apiService: OrganizationIntegrationApiService,
private organizationIntegrationService: OrganizationIntegrationService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngAfterViewInit() {
this.organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$])
.pipe(takeUntil(this.destroyed$))
.subscribe(([theme, systemTheme]) => {
@@ -125,6 +130,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
const dialog = openHecConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
configuration: this.organizationIntegrationService.convertToJson<HecConfiguration>(
this.integrationSettings.configuration,
),
template: this.organizationIntegrationService.convertToJson<HecConfigurationTemplate>(
this.integrationSettings.template,
),
},
});
@@ -135,11 +146,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
return;
}
// create integration and configuration objects
const integration = new HecConfiguration(result.url, result.bearerToken, result.service);
const configurationTemplate = new HecConfigurationTemplate(result.index, result.service);
// save the integration
try {
const dbResponse = await this.saveHecIntegration(result.configuration);
this.isConnected = !!dbResponse.id;
} catch {
const dbResponse = await this.organizationIntegrationService.saveHec(
this.organizationId,
this.integrationSettings.name,
integration,
configurationTemplate,
);
if (!!dbResponse.integration && !!dbResponse.configuration) {
this.isConnected = true;
this.integrationSettings.configuration = dbResponse.integration.configuration;
this.integrationSettings.template = dbResponse.configuration.template;
}
} catch (err) {
this.isConnected = false;
// eslint-disable-next-line no-console
console.error("Failed to save integration", err);
this.toastService.showToast({
variant: "error",
title: null,
@@ -147,34 +176,5 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
});
return;
}
// update the configuration
this.integrationSettings.configuration = result.configuration;
}
async saveHecIntegration(configuration: string): Promise<OrganizationIntegrationResponse> {
const organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
const request = new OrganizationIntegrationRequest(
OrganizationIntegrationType.Hec,
configuration,
);
const integrations = await this.apiService.getOrganizationIntegrations(organizationId);
const existingIntegration = integrations.find(
(i) => i.type === OrganizationIntegrationType.Hec,
);
if (existingIntegration) {
return await this.apiService.updateOrganizationIntegration(
organizationId,
existingIntegration.id,
request,
);
} else {
return await this.apiService.createOrganizationIntegration(organizationId, request);
}
}
}

View File

@@ -150,12 +150,10 @@ describe("ConnectDialogHecComponent", () => {
expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock,
configuration: JSON.stringify({
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
}),
url: "https://test.com",
bearerToken: "token",
index: "1",
service: "Test Service",
success: true,
error: null,
});

View File

@@ -1,6 +1,11 @@
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
// eslint-disable-next-line no-restricted-imports
import {
HecConfiguration,
HecConfigurationTemplate,
} from "@bitwarden/bit-common/dirt/integrations";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
@@ -8,11 +13,16 @@ import { Integration } from "../../models";
export type HecConnectDialogParams = {
settings: Integration;
configuration: HecConfiguration | null;
template: HecConfigurationTemplate | null;
};
export interface HecConnectDialogResult {
integrationSettings: Integration;
configuration: string;
url: string;
bearerToken: string;
index: string;
service: string;
success: boolean;
error: string | null;
}
@@ -37,16 +47,12 @@ export class ConnectHecDialogComponent implements OnInit {
) {}
ngOnInit(): void {
const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? "");
if (settings) {
this.formGroup.patchValue({
url: settings?.url || "",
bearerToken: settings?.bearerToken || "",
index: settings?.index || "",
service: this.connectInfo.settings.name,
});
}
this.formGroup.patchValue({
url: this.connectInfo.configuration?.uri || "",
bearerToken: this.connectInfo.configuration?.token || "",
index: this.connectInfo.template?.index || "",
service: this.connectInfo.settings.name,
});
}
getSettingsAsJson(configuration: string) {
@@ -62,7 +68,10 @@ export class ConnectHecDialogComponent implements OnInit {
const result: HecConnectDialogResult = {
integrationSettings: this.connectInfo.settings,
configuration: JSON.stringify(formJson),
url: formJson.url,
bearerToken: formJson.bearerToken,
index: formJson.index,
service: formJson.service,
success: true,
error: null,
};

View File

@@ -5,8 +5,6 @@ import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
@@ -21,6 +19,7 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
import { OrganizationIntegrationService } from "../services/organization-integration.service";
import { IntegrationGridComponent } from "./integration-grid.component";
@@ -28,7 +27,7 @@ describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const mockActivatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const mockIntegrationService = mock<OrganizationIntegrationService>();
const integrations: Integration[] = [
{
name: "Integration 1",
@@ -74,10 +73,7 @@ describe("IntegrationGridComponent", () => {
provide: ActivatedRoute,
useValue: mockActivatedRoute,
},
{
provide: OrganizationIntegrationApiService,
useValue: mockOrgIntegrationApiService,
},
{ provide: OrganizationIntegrationService, useValue: mockIntegrationService },
{
provide: ToastService,
useValue: mock<ToastService>(),

View File

@@ -21,4 +21,5 @@ export type Integration = {
isConnected?: boolean;
canSetupConnection?: boolean;
configuration?: string;
template?: string;
};

View File

@@ -0,0 +1,347 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import {
OrganizationIntegrationApiService,
OrganizationIntegrationConfigurationApiService,
} from "@bitwarden/bit-common/dirt/integrations";
import { OrganizationIntegrationService } from "./organization-integration.service";
describe("OrganizationIntegrationService", () => {
let service: OrganizationIntegrationService;
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const mockOrgIntegrationConfigurationApiService =
mock<OrganizationIntegrationConfigurationApiService>();
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
{
provide: OrganizationIntegrationConfigurationApiService,
useValue: mockOrgIntegrationConfigurationApiService,
},
],
});
service = TestBed.inject(OrganizationIntegrationService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
describe("getIntegrationsAndConfigurations", () => {
const orgId = "org-123" as any;
beforeEach(() => {
mockOrgIntegrationApiService.getOrganizationIntegrations.mockReset();
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReset();
service["integrations"].next([]);
service["integrationConfigurations"].next([]);
});
it("should fetch integrations and their configurations and update observables", async () => {
const integration1 = { id: "int-1", type: "type1" } as any;
const integration2 = { id: "int-2", type: "type2" } as any;
const config1 = [{ id: "conf-1", template: "{}" }] as any;
const config2 = [{ id: "conf-2", template: "{}" }] as any;
mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([
integration1,
integration2,
]);
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockImplementation(
(_, integrationId) => {
if (integrationId === "int-1") {
return Promise.resolve(config1);
}
if (integrationId === "int-2") {
return Promise.resolve(config2);
}
return Promise.resolve([]);
},
);
const result = await service.getIntegrationsAndConfigurations(orgId);
expect(mockOrgIntegrationApiService.getOrganizationIntegrations).toHaveBeenCalledWith(orgId);
expect(
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations,
).toHaveBeenCalledWith(orgId, "int-1");
expect(
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations,
).toHaveBeenCalledWith(orgId, "int-2");
expect(result.integrations).toEqual([integration1, integration2]);
expect(result.configurations.length).toBe(2);
expect(result.configurations[0].integrationId).toBe("int-1");
expect(result.configurations[1].integrationId).toBe("int-2");
// Check observables updated
service.integrations$.subscribe((integrations) => {
expect(integrations).toEqual([integration1, integration2]);
});
service.integrationConfigurations$.subscribe((configs) => {
expect(configs.length).toBe(2);
});
});
it("should handle no integrations", async () => {
mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]);
const result = await service.getIntegrationsAndConfigurations(orgId);
expect(result.integrations).toEqual([]);
expect(result.configurations).toEqual([]);
service.integrations$.subscribe((integrations) => {
expect(integrations).toEqual([]);
});
service.integrationConfigurations$.subscribe((configs) => {
expect(configs).toEqual([]);
});
});
it("should handle integrations with no configurations", async () => {
const integration = { id: "int-1", type: "type1" } as any;
mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([integration]);
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockResolvedValue(
[],
);
const result = await service.getIntegrationsAndConfigurations(orgId);
expect(result.integrations).toEqual([integration]);
expect(result.configurations.length).toBe(1);
expect(result.configurations[0].integrationId).toBe("int-1");
expect(result.configurations[0].configurationResponses).toEqual([]);
});
});
describe("saveHec", () => {
const organizationId = "org-123" as any;
const serviceName = "splunk";
const hecConfiguration = { toString: () => '{"token":"abc"}' } as any;
const hecConfigurationTemplate = { toString: () => '{"service":"splunk"}' } as any;
beforeEach(() => {
mockOrgIntegrationApiService.getOrganizationIntegrations.mockReset();
mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReset();
service["integrations"].next([]);
service["integrationConfigurations"].next([]);
jest.clearAllMocks();
});
it("should save HEC integration and configuration successfully", async () => {
const integrationResponse = { id: "int-1", type: "Hec" } as any;
const configurationResponse = { id: "conf-1", template: '{"service":"splunk"}' } as any;
jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse);
jest
.spyOn(service, "saveHecIntegrationConfiguration")
.mockResolvedValue(configurationResponse);
const result = await service.saveHec(
organizationId,
serviceName,
hecConfiguration,
hecConfigurationTemplate,
);
expect(service.saveHecIntegration).toHaveBeenCalledWith(organizationId, hecConfiguration);
expect(service.saveHecIntegrationConfiguration).toHaveBeenCalledWith(
organizationId,
integrationResponse.id,
serviceName,
hecConfigurationTemplate,
);
expect(result).toEqual({
integration: integrationResponse,
configuration: configurationResponse,
});
});
it("should throw error if integrationResponse.id is missing", async () => {
const integrationResponse = { type: "Hec" } as any;
jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse);
await expect(
service.saveHec(organizationId, serviceName, hecConfiguration, hecConfigurationTemplate),
).rejects.toThrow("Failed to save HEC integration");
});
it("should throw error if configurationResponse.id is missing", async () => {
const integrationResponse = { id: "int-1", type: "Hec" } as any;
const configurationResponse = { template: '{"service":"splunk"}' } as any;
jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse);
jest
.spyOn(service, "saveHecIntegrationConfiguration")
.mockResolvedValue(configurationResponse);
await expect(
service.saveHec(organizationId, serviceName, hecConfiguration, hecConfigurationTemplate),
).rejects.toThrow("Failed to save HEC integration configuration");
});
});
describe("getIntegrationConfiguration", () => {
const integrationId = "int-1" as any;
const serviceName = "splunk";
const otherServiceName = "datadog";
it("should return null if integrationConfigurations is empty", () => {
const result = service.getIntegrationConfiguration(integrationId, serviceName, []);
expect(result).toBeNull();
});
it("should return null if no integrationConfigs found for integrationId", () => {
const integrationConfigurations = [
{
integrationId: "int-2",
configurationResponses: [{ id: "conf-1", template: '{"service":"splunk"}' }],
},
] as any;
const result = service.getIntegrationConfiguration(
integrationId,
serviceName,
integrationConfigurations,
);
expect(result).toBeNull();
});
it("should return null if no configurationResponses match the service", () => {
const sampleIntegrationConfigurations = [
{
integrationId,
configurationResponses: [{ id: "conf-1", template: '{"service":"splunk"}' }],
},
] as any;
const result = service.getIntegrationConfiguration(
integrationId,
otherServiceName,
sampleIntegrationConfigurations,
);
expect(result).toBeNull();
});
it("should return the configuration template if service matches", () => {
const template = { service: "splunk", token: "abc" };
const integrationConfigurations = [
{
integrationId,
configurationResponses: [
{ id: "conf-1", template: JSON.stringify(template) },
{ id: "conf-2", template: '{"service":"datadog"}' },
],
},
] as any;
const result = service.getIntegrationConfiguration(
integrationId,
serviceName,
integrationConfigurations,
);
expect(result).toEqual(template);
});
it("should return the first matching configuration if multiple match", () => {
const template1 = { service: "splunk", token: "abc" };
const template2 = { service: "splunk", token: "def" };
const integrationConfigurations = [
{
integrationId,
configurationResponses: [
{ id: "conf-1", template: JSON.stringify(template1) },
{ id: "conf-2", template: JSON.stringify(template2) },
],
},
] as any;
const result = service.getIntegrationConfiguration(
integrationId,
serviceName,
integrationConfigurations,
);
expect(result).toEqual(template1);
});
it("should skip invalid JSON templates", () => {
const template = { service: "splunk", token: "abc" };
const integrationConfigurations = [
{
integrationId,
configurationResponses: [
{ id: "conf-1", template: "invalid-json" },
{ id: "conf-2", template: JSON.stringify(template) },
],
},
] as any;
const result = service.getIntegrationConfiguration(
integrationId,
serviceName,
integrationConfigurations,
);
expect(result).toEqual(template);
});
it("should return null if all templates are invalid JSON", () => {
const integrationConfigurations = [
{
integrationId,
configurationResponses: [
{ id: "conf-1", template: "invalid-json" },
{ id: "conf-2", template: "" },
],
},
] as any;
const result = service.getIntegrationConfiguration(
integrationId,
serviceName,
integrationConfigurations,
);
expect(result).toBeNull();
});
});
describe("convertToJson", () => {
it("should parse valid JSON string and return object", () => {
const jsonString = '{"foo":"bar","num":42}';
const result = service.convertToJson<{ foo: string; num: number }>(jsonString);
expect(result).toEqual({ foo: "bar", num: 42 });
});
it("should return null for invalid JSON string", () => {
const invalidJson = '{"foo":bar}';
const result = service.convertToJson<any>(invalidJson);
expect(result).toBeNull();
});
it("should return null for empty string", () => {
const result = service.convertToJson<any>("");
expect(result).toBeNull();
});
it("should parse JSON arrays", () => {
const jsonString = "[1,2,3]";
const result = service.convertToJson<number[]>(jsonString);
expect(result).toEqual([1, 2, 3]);
});
it("should parse JSON boolean", () => {
const jsonString = "true";
const result = service.convertToJson<boolean>(jsonString);
expect(result).toBe(true);
});
it("should parse JSON number", () => {
const jsonString = "123";
const result = service.convertToJson<number>(jsonString);
expect(result).toBe(123);
});
it("should parse JSON null", () => {
const jsonString = "null";
const result = service.convertToJson<any>(jsonString);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,281 @@
import { Injectable } from "@angular/core";
import { BehaviorSubject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import {
OrganizationIntegrationApiService,
OrganizationIntegrationConfigurationApiService,
OrganizationIntegrationConfigurationRequest,
OrganizationIntegrationConfigurationResponse,
HecConfiguration,
OrganizationIntegrationRequest,
OrganizationIntegrationResponse,
OrganizationIntegrationType,
HecConfigurationTemplate,
OrganizationIntegrationConfigurationResponseWithIntegrationId,
} from "@bitwarden/bit-common/dirt/integrations";
import { EventType } from "@bitwarden/common/enums";
import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid";
@Injectable({
providedIn: "root",
})
export class OrganizationIntegrationService {
private integrations = new BehaviorSubject<OrganizationIntegrationResponse[]>([]);
integrations$ = this.integrations.asObservable();
private integrationConfigurations = new BehaviorSubject<
OrganizationIntegrationConfigurationResponseWithIntegrationId[]
>([]);
integrationConfigurations$ = this.integrationConfigurations.asObservable();
constructor(
private integrationApiService: OrganizationIntegrationApiService,
private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService,
) {}
/*
* Fetches the integrations and their configurations for a specific organization.
* @param orgId The ID of the organization.
* Invoke this method to retrieve the integrations into observable.
*/
async getIntegrationsAndConfigurations(orgId: OrganizationId) {
const promises: Promise<void>[] = [];
const integrations = await this.integrationApiService.getOrganizationIntegrations(orgId);
const integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[] =
[];
integrations.forEach((integration) => {
const promise = this.integrationConfigurationApiService
.getOrganizationIntegrationConfigurations(orgId, integration.id)
.then((configs) => {
const mappedConfigurations =
new OrganizationIntegrationConfigurationResponseWithIntegrationId(
integration.id,
configs,
);
integrationConfigurations.push(mappedConfigurations);
});
promises.push(promise);
});
await Promise.all(promises);
this.integrations.next(integrations);
this.integrationConfigurations.next(integrationConfigurations);
return {
integrations: integrations,
configurations: integrationConfigurations,
};
}
async saveHec(
organizationId: OrganizationId,
service: string,
hecConfiguration: HecConfiguration,
hecConfigurationTemplate: HecConfigurationTemplate,
) {
const integrationResponse = await this.saveHecIntegration(organizationId, hecConfiguration);
if (!integrationResponse.id) {
throw new Error("Failed to save HEC integration");
}
// Save the configuration for the HEC integration
const configurationResponse = await this.saveHecIntegrationConfiguration(
organizationId,
integrationResponse.id,
service,
hecConfigurationTemplate,
);
if (!configurationResponse.id) {
throw new Error("Failed to save HEC integration configuration");
}
return {
integration: integrationResponse,
configuration: configurationResponse,
};
}
/**
* Saves the HEC integration configuration for a specific organization.
* @param organizationId The ID of the organization.
* @param configuration The HEC integration configuration.
* @param index The index of the integration configuration to update, if it exists.
* @returns The saved or updated integration response.
*
* This method checks if an existing HEC integration exists for the organization.
* If it does, it updates the existing integration; otherwise, it creates a new one.
* The method returns the saved or updated integration response.
*/
async saveHecIntegration(
organizationId: OrganizationId,
hecConfiguration: HecConfiguration,
): Promise<OrganizationIntegrationResponse> {
const request = new OrganizationIntegrationRequest(
OrganizationIntegrationType.Hec,
hecConfiguration.toString(),
);
// find the existing integration
const existingIntegration = this.integrations.value.find(
(i) => i.type === OrganizationIntegrationType.Hec,
);
if (existingIntegration) {
// existing integration record found, invoke update API endpoint
const updatedIntegration = await this.integrationApiService.updateOrganizationIntegration(
organizationId,
existingIntegration.id,
request,
);
// update our observable with the updated integration
const updatedIntegrations = this.integrations.value.map((integration) => {
if (integration.id === existingIntegration.id) {
return updatedIntegration;
}
return integration;
});
this.integrations.next(updatedIntegrations);
return updatedIntegration;
} else {
// no existing integration found, invoke create API endpoint
const newIntegration = await this.integrationApiService.createOrganizationIntegration(
organizationId,
request,
);
// add this to our integrations observable
this.integrations.next([...this.integrations.value, newIntegration]);
return newIntegration;
}
}
/** * Saves the HEC integration configuration for a specific organization and integration.
* @param organizationId The ID of the organization.
* @param integrationId The ID of the integration.
* @param configurationTemplate The HEC integration configuration.
* @returns The saved or updated integration configuration response.
*
* This method checks if an existing configuration exists for the given integration.
* If it does, it updates the existing configuration; otherwise, it creates a new one.
* The method returns the saved or updated configuration response.
*/
async saveHecIntegrationConfiguration(
organizationId: OrganizationId,
integrationId: OrganizationIntegrationId,
service: string,
configurationTemplate: HecConfigurationTemplate,
): Promise<OrganizationIntegrationConfigurationResponse> {
const request = new OrganizationIntegrationConfigurationRequest(
EventType.Organization_Updated,
null,
null,
configurationTemplate.toString(),
);
// check if we have an existing configuration for this integration - in case of new records
const integrationConfigurations = this.integrationConfigurations.value;
// find the existing configuration
const existingConfigurations = integrationConfigurations
.filter((config) => config.integrationId === integrationId)
.flatMap((config) => config.configurationResponses || []);
const existingConfiguration =
existingConfigurations.length > 0
? existingConfigurations.find(
(config) =>
config.template &&
this.convertToJson<HecConfigurationTemplate>(config.template)?.service === service,
)
: null;
if (existingConfiguration) {
// existing configuration found, invoke update API endpoint
const updatedConfiguration =
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration(
organizationId,
integrationId,
existingConfiguration.id,
request,
);
// update our configurations for the integration
integrationConfigurations.forEach((integrationConfig) => {
if (integrationConfig.integrationId === integrationId) {
integrationConfig.configurationResponses = integrationConfig.configurationResponses.map(
(config) => {
return config.id === existingConfiguration.id ? updatedConfiguration : config;
},
);
}
});
this.integrationConfigurations.next(integrationConfigurations);
return updatedConfiguration;
} else {
// no existing configuration found, invoke create API endpoint
const newConfiguration =
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
organizationId,
integrationId,
request,
);
// add the new configuration to the integration configurations
const integrationConfig = integrationConfigurations.find(
(config) => config.integrationId === integrationId,
);
if (integrationConfig) {
integrationConfig.configurationResponses.push(newConfiguration);
}
this.integrationConfigurations.next(integrationConfigurations);
return newConfiguration;
}
}
getIntegrationConfiguration(
integrationId: OrganizationIntegrationId,
service: string,
integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[],
): HecConfigurationTemplate | null {
if (integrationConfigurations.length === 0) {
return null;
}
const integrationConfigs = integrationConfigurations.find(
(config) => config.integrationId === integrationId,
);
if (!integrationConfigs) {
return null;
}
for (const config of integrationConfigs.configurationResponses) {
const template = this.convertToJson<HecConfigurationTemplate>(config.template || "");
if (template && template.service === service) {
return template;
}
}
return null;
}
convertToJson<T>(jsonString: string): T | null {
try {
return JSON.parse(jsonString) as T;
} catch {
return null;
}
}
}

View File

@@ -42,7 +42,10 @@ import {
LoginEmailService,
} from "@bitwarden/auth/common";
// eslint-disable-next-line no-restricted-imports
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
import {
OrganizationIntegrationApiService,
OrganizationIntegrationConfigurationApiService,
} from "@bitwarden/bit-common/dirt/integrations";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
@@ -402,6 +405,11 @@ const safeProviders: SafeProvider[] = [
useClass: OrganizationIntegrationApiService,
deps: [ApiService],
}),
safeProvider({
provide: OrganizationIntegrationConfigurationApiService,
useClass: OrganizationIntegrationConfigurationApiService,
deps: [ApiService],
}),
];
@NgModule({

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

@@ -9,3 +9,40 @@ export class OrganizationIntegrationRequest {
this.configuration = configuration;
}
}
/*
* Represents the configuration for a HEC (HTTP Event Collector) integration.
* Configuration model that is required by OrganizationIntegration.
*/
export class HecConfiguration {
uri: string;
scheme = "bearer";
token: string;
service: string;
constructor(uri: string, token: string, service: string) {
this.uri = uri;
this.token = token;
this.service = service;
}
toString(): string {
return JSON.stringify(this);
}
}
export class HecConfigurationTemplate {
event = "#EventMessage";
source = "Bitwarden";
index: string;
service: string;
constructor(index: string, service: string) {
this.index = index;
this.service = service;
}
toString(): string {
return JSON.stringify(this);
}
}