1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +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

@@ -19,7 +19,6 @@ import { deepLinkGuard } from "../../auth/guards/deep-link/deep-link.guard";
import { VaultModule } from "./collections/vault.module"; import { VaultModule } from "./collections/vault.module";
import { organizationPermissionsGuard } from "./guards/org-permissions.guard"; import { organizationPermissionsGuard } from "./guards/org-permissions.guard";
import { organizationRedirectGuard } from "./guards/org-redirect.guard"; import { organizationRedirectGuard } from "./guards/org-redirect.guard";
import { AdminConsoleIntegrationsComponent } from "./integrations/integrations.component";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component"; import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { GroupsComponent } from "./manage/groups.component"; import { GroupsComponent } from "./manage/groups.component";
@@ -39,14 +38,6 @@ const routes: Routes = [
path: "vault", path: "vault",
loadChildren: () => VaultModule, loadChildren: () => VaultModule,
}, },
{
path: "integrations",
canActivate: [organizationPermissionsGuard(canAccessIntegrations)],
component: AdminConsoleIntegrationsComponent,
data: {
titleId: "integrations",
},
},
{ {
path: "settings", path: "settings",
loadChildren: () => loadChildren: () =>
@@ -103,10 +94,6 @@ function getOrganizationRoute(organization: Organization): string {
return undefined; return undefined;
} }
function canAccessIntegrations(organization: Organization) {
return organization.canAccessIntegrations;
}
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule], exports: [RouterModule],

View File

@@ -1,4 +0,0 @@
export * from "./integrations.pipe";
export * from "./integration-card/integration-card.component";
export * from "./integration-grid/integration-grid.component";
export * from "./models";

View File

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

View File

@@ -7437,6 +7437,9 @@
"off": { "off": {
"message": "Off" "message": "Off"
}, },
"connected": {
"message": "Connected"
},
"members": { "members": {
"message": "Members" "message": "Members"
}, },
@@ -9694,6 +9697,15 @@
} }
} }
}, },
"updateIntegrationButtonDesc": {
"message": "Update $INTEGRATION$",
"placeholders": {
"integration": {
"content": "$1",
"example": "Crowdstrike"
}
}
},
"integrationCardTooltip": { "integrationCardTooltip": {
"message": "Launch $INTEGRATION$ implementation guide.", "message": "Launch $INTEGRATION$ implementation guide.",
"placeholders": { "placeholders": {

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

@@ -1,4 +1,6 @@
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
import { OrganizationIntegration } from "./organization-integration";
/** Integration or SDK */ /** Integration or SDK */
export type Integration = { export type Integration = {
@@ -21,4 +23,8 @@ export type Integration = {
isConnected?: boolean; isConnected?: boolean;
canSetupConnection?: boolean; canSetupConnection?: boolean;
configuration?: string; configuration?: string;
template?: string;
// OrganizationIntegration
organizationIntegration?: OrganizationIntegration | null;
}; };

View File

@@ -1,6 +1,9 @@
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { BaseResponse } from "@bitwarden/common/models/response/base.response"; 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 { export class OrganizationIntegrationConfigurationResponse extends BaseResponse {
id: OrganizationIntegrationConfigurationId; id: OrganizationIntegrationConfigurationId;
@@ -18,3 +21,16 @@ export class OrganizationIntegrationConfigurationResponse extends BaseResponse {
this.template = this.getResponseProperty("Template"); 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 { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid"; import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid";
import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; import { OrganizationIntegrationRequest } from "../models/organization-integration-request";
import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; import { OrganizationIntegrationResponse } from "../models/organization-integration-response";
@Injectable()
export class OrganizationIntegrationApiService { export class OrganizationIntegrationApiService {
constructor(private apiService: ApiService) {} constructor(private apiService: ApiService) {}

View File

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

View File

@@ -85,6 +85,14 @@ const routes: Routes = [
(m) => m.AccessIntelligenceModule, (m) => m.AccessIntelligenceModule,
), ),
}, },
{
path: "integrations",
canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)],
loadChildren: () =>
import("../../dirt/organization-integrations/organization-integrations.module").then(
(m) => m.OrganizationIntegrationsModule,
),
},
], ],
}, },
]; ];

View File

@@ -22,7 +22,7 @@
@if (showConnectedBadge()) { @if (showConnectedBadge()) {
<span class="tw-ml-3"> <span class="tw-ml-3">
@if (isConnected) { @if (isConnected) {
<span bitBadge variant="success">{{ "on" | i18n }}</span> <span bitBadge variant="success">{{ "connected" | i18n }}</span>
} }
@if (!isConnected) { @if (!isConnected) {
<span bitBadge>{{ "off" | i18n }}</span> <span bitBadge>{{ "off" | i18n }}</span>
@@ -34,7 +34,11 @@
@if (canSetupConnection) { @if (canSetupConnection) {
<button type="button" class="tw-mt-3" bitButton (click)="setupConnection()"> <button type="button" class="tw-mt-3" bitButton (click)="setupConnection()">
<span>{{ "connectIntegrationButtonDesc" | i18n: name }}</span> @if (isUpdateAvailable) {
<span>{{ "updateIntegrationButtonDesc" | i18n: name }}</span>
} @else {
<span>{{ "connectIntegrationButtonDesc" | i18n: name }}</span>
}
</button> </button>
} }
@@ -44,6 +48,7 @@
[href]="linkURL" [href]="linkURL"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
title="{{ linkURL }}"
> >
</a> </a>
} }

View File

@@ -1,27 +1,34 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { openHecConnectDialog } from "../integration-dialog";
import { IntegrationCardComponent } from "./integration-card.component"; import { IntegrationCardComponent } from "./integration-card.component";
jest.mock("../integration-dialog", () => ({
openHecConnectDialog: jest.fn(),
}));
describe("IntegrationCardComponent", () => { describe("IntegrationCardComponent", () => {
let component: IntegrationCardComponent; let component: IntegrationCardComponent;
let fixture: ComponentFixture<IntegrationCardComponent>; let fixture: ComponentFixture<IntegrationCardComponent>;
const mockI18nService = mock<I18nService>(); const mockI18nService = mock<I18nService>();
const activatedRoute = mock<ActivatedRoute>(); const activatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>(); const mockIntegrationService = mock<HecOrganizationIntegrationService>();
const dialogService = mock<DialogService>();
const toastService = mock<ToastService>();
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light); const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light); const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
@@ -43,8 +50,9 @@ describe("IntegrationCardComponent", () => {
{ provide: I18nPipe, useValue: mock<I18nPipe>() }, { provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService }, { provide: I18nService, useValue: mockI18nService },
{ provide: ActivatedRoute, useValue: activatedRoute }, { provide: ActivatedRoute, useValue: activatedRoute },
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService }, { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService },
{ provide: ToastService, useValue: mock<ToastService>() }, { provide: ToastService, useValue: toastService },
{ provide: DialogService, useValue: dialogService },
], ],
}).compileComponents(); }).compileComponents();
}); });
@@ -186,27 +194,160 @@ describe("IntegrationCardComponent", () => {
}); });
}); });
describe("connected badge", () => { describe("showNewBadge", () => {
it("shows connected badge when isConnected is true", () => { beforeEach(() => {
component.isConnected = true; jest.useFakeTimers();
jest.setSystemTime(new Date("2024-06-01"));
});
afterEach(() => {
jest.useRealTimers();
});
it("returns false when newBadgeExpiration is undefined", () => {
component.newBadgeExpiration = undefined;
expect(component.showNewBadge()).toBe(false);
});
it("returns false when newBadgeExpiration is an invalid date", () => {
component.newBadgeExpiration = "invalid-date";
expect(component.showNewBadge()).toBe(false);
});
it("returns true when newBadgeExpiration is in the future", () => {
component.newBadgeExpiration = "2024-06-02";
expect(component.showNewBadge()).toBe(true);
});
it("returns false when newBadgeExpiration is today", () => {
component.newBadgeExpiration = "2024-06-01";
expect(component.showNewBadge()).toBe(false);
});
it("returns false when newBadgeExpiration is in the past", () => {
component.newBadgeExpiration = "2024-05-31";
expect(component.showNewBadge()).toBe(false);
});
});
describe("showConnectedBadge", () => {
it("returns true when canSetupConnection is true", () => {
component.canSetupConnection = true;
expect(component.showConnectedBadge()).toBe(true); expect(component.showConnectedBadge()).toBe(true);
}); });
it("does not show connected badge when isConnected is false", () => { it("returns false when canSetupConnection is false", () => {
component.isConnected = false; component.canSetupConnection = false;
fixture.detectChanges(); expect(component.showConnectedBadge()).toBe(false);
const name = fixture.nativeElement.querySelector("h3 > span > span > span");
expect(name.textContent).toContain("off");
// when isConnected is true/false, the badge should be shown as on/off
// when isConnected is undefined, the badge should not be shown
expect(component.showConnectedBadge()).toBe(true);
}); });
it("does not show connected badge when isConnected is undefined", () => { it("returns false when canSetupConnection is undefined", () => {
component.isConnected = undefined; component.canSetupConnection = undefined;
expect(component.showConnectedBadge()).toBe(false); expect(component.showConnectedBadge()).toBe(false);
}); });
}); });
describe("setupConnection", () => {
beforeEach(() => {
component.integrationSettings = {
organizationIntegration: {
id: "integration-id",
configuration: {},
integrationConfiguration: [{ id: "config-id" }],
},
name: OrganizationIntegrationServiceType.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
jest.resetAllMocks();
});
it("should not proceed if dialog is cancelled", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({ success: false }),
});
await component.setupConnection();
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
});
it("should call updateHec if isUpdateAvailable is true", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: true,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalledWith(
"org-id",
"integration-id",
"config-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
);
expect(mockIntegrationService.saveHec).not.toHaveBeenCalled();
});
it("should call saveHec if isUpdateAvailable is false", async () => {
component.integrationSettings = {
organizationIntegration: null,
name: OrganizationIntegrationServiceType.CrowdStrike,
} as any;
component.organizationId = "org-id" as any;
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: true,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false);
mockIntegrationService.saveHec.mockResolvedValue(undefined);
await component.setupConnection();
expect(mockIntegrationService.saveHec).toHaveBeenCalledWith(
"org-id",
OrganizationIntegrationServiceType.CrowdStrike,
"test-url",
"token",
"index",
);
expect(mockIntegrationService.updateHec).not.toHaveBeenCalled();
});
it("should show toast on error", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: true,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.updateHec.mockRejectedValue(new Error("fail"));
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: mockI18nService.t("failedToSaveIntegration"),
});
});
});
}); });

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
@@ -13,22 +11,17 @@ import { ActivatedRoute } from "@angular/router";
import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs"; import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
OrganizationIntegrationType, import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
OrganizationIntegrationRequest,
OrganizationIntegrationResponse,
OrganizationIntegrationApiService,
} from "@bitwarden/bit-common/dirt/integrations/index";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SharedModule } from "../../../../../../shared/shared.module";
import { openHecConnectDialog } from "../integration-dialog/index"; import { openHecConnectDialog } from "../integration-dialog/index";
import { Integration } from "../models";
@Component({ @Component({
selector: "app-integration-card", selector: "app-integration-card",
@@ -37,13 +30,13 @@ import { Integration } from "../models";
}) })
export class IntegrationCardComponent implements AfterViewInit, OnDestroy { export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
private destroyed$: Subject<void> = new Subject(); private destroyed$: Subject<void> = new Subject();
@ViewChild("imageEle") imageEle: ElementRef<HTMLImageElement>; @ViewChild("imageEle") imageEle!: ElementRef<HTMLImageElement>;
@Input() name: string; @Input() name: string = "";
@Input() image: string; @Input() image: string = "";
@Input() imageDarkMode?: string; @Input() imageDarkMode: string = "";
@Input() linkURL: string; @Input() linkURL: string = "";
@Input() integrationSettings: Integration; @Input() integrationSettings!: Integration;
/** Adds relevant `rel` attribute to external links */ /** Adds relevant `rel` attribute to external links */
@Input() externalURL?: boolean; @Input() externalURL?: boolean;
@@ -56,19 +49,24 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
*/ */
@Input() newBadgeExpiration?: string; @Input() newBadgeExpiration?: string;
@Input() description?: string; @Input() description?: string;
@Input() isConnected?: boolean;
@Input() canSetupConnection?: boolean; @Input() canSetupConnection?: boolean;
organizationId: OrganizationId;
constructor( constructor(
private themeStateService: ThemeStateService, private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE) @Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>, private systemTheme$: Observable<ThemeType>,
private dialogService: DialogService, private dialogService: DialogService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private apiService: OrganizationIntegrationApiService, private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
private toastService: ToastService, private toastService: ToastService,
private i18nService: I18nService, private i18nService: I18nService,
) {} ) {
this.organizationId = this.activatedRoute.snapshot.paramMap.get(
"organizationId",
) as OrganizationId;
}
ngAfterViewInit() { ngAfterViewInit() {
combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$]) combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$])
@@ -116,8 +114,16 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
return expirationDate > new Date(); return expirationDate > new Date();
} }
get isConnected(): boolean {
return !!this.integrationSettings.organizationIntegration?.configuration;
}
showConnectedBadge(): boolean { showConnectedBadge(): boolean {
return this.isConnected !== undefined; return this.canSetupConnection ?? false;
}
get isUpdateAvailable(): boolean {
return !!this.integrationSettings.organizationIntegration;
} }
async setupConnection() { async setupConnection() {
@@ -135,43 +141,41 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
return; return;
} }
// save the integration
try { try {
const dbResponse = await this.saveHecIntegration(result.configuration); if (this.isUpdateAvailable) {
this.isConnected = !!dbResponse.id; const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
await this.hecOrganizationIntegrationService.updateHec(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
} else {
await this.hecOrganizationIntegrationService.saveHec(
this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url,
result.bearerToken,
result.index,
);
}
} catch { } catch {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: null, title: "",
message: this.i18nService.t("failedToSaveIntegration"), message: this.i18nService.t("failedToSaveIntegration"),
}); });
return; return;
} }
} }
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

@@ -28,7 +28,11 @@
</div> </div>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading"> <button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }} @if (isUpdateAvailable) {
{{ "update" | i18n }}
} @else {
{{ "save" | i18n }}
}
</button> </button>
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading"> <button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }} {{ "cancel" | i18n }}

View File

@@ -3,14 +3,13 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
import { import {
ConnectHecDialogComponent, ConnectHecDialogComponent,
HecConnectDialogParams, HecConnectDialogParams,
@@ -70,7 +69,9 @@ describe("ConnectDialogHecComponent", () => {
canSetupConnection: true, canSetupConnection: true,
type: IntegrationType.EVENT, type: IntegrationType.EVENT,
} as Integration; } as Integration;
const connectInfo: HecConnectDialogParams = { settings: integrationMock }; const connectInfo: HecConnectDialogParams = {
settings: integrationMock, // Provide appropriate mock template if needed
};
beforeEach(async () => { beforeEach(async () => {
dialogRefMock = mock<DialogRef<HecConnectDialogResult>>(); dialogRefMock = mock<DialogRef<HecConnectDialogResult>>();
@@ -150,12 +151,10 @@ describe("ConnectDialogHecComponent", () => {
expect(dialogRefMock.close).toHaveBeenCalledWith({ expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock, integrationSettings: integrationMock,
configuration: JSON.stringify({ url: "https://test.com",
url: "https://test.com", bearerToken: "token",
bearerToken: "token", index: "1",
index: "1", service: "Test Service",
service: "Test Service",
}),
success: true, success: true,
error: null, error: null,
}); });

View File

@@ -1,18 +1,22 @@
import { Component, Inject, OnInit } from "@angular/core"; import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; import { FormBuilder, Validators } from "@angular/forms";
import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { Integration } from "../../models";
export type HecConnectDialogParams = { export type HecConnectDialogParams = {
settings: Integration; settings: Integration;
}; };
export interface HecConnectDialogResult { export interface HecConnectDialogResult {
integrationSettings: Integration; integrationSettings: Integration;
configuration: string; url: string;
bearerToken: string;
index: string;
service: string;
success: boolean; success: boolean;
error: string | null; error: string | null;
} }
@@ -23,6 +27,8 @@ export interface HecConnectDialogResult {
}) })
export class ConnectHecDialogComponent implements OnInit { export class ConnectHecDialogComponent implements OnInit {
loading = false; loading = false;
hecConfig: HecConfiguration | null = null;
hecTemplate: HecTemplate | null = null;
formGroup = this.formBuilder.group({ formGroup = this.formBuilder.group({
url: ["", [Validators.required, Validators.pattern("https?://.+")]], url: ["", [Validators.required, Validators.pattern("https?://.+")]],
bearerToken: ["", Validators.required], bearerToken: ["", Validators.required],
@@ -37,24 +43,23 @@ export class ConnectHecDialogComponent implements OnInit {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? ""); this.hecConfig =
this.connectInfo.settings.organizationIntegration?.getConfiguration<HecConfiguration>() ??
null;
this.hecTemplate =
this.connectInfo.settings.organizationIntegration?.integrationConfiguration?.[0]?.getTemplate<HecTemplate>() ??
null;
if (settings) { this.formGroup.patchValue({
this.formGroup.patchValue({ url: this.hecConfig?.uri || "",
url: settings?.url || "", bearerToken: this.hecConfig?.token || "",
bearerToken: settings?.bearerToken || "", index: this.hecTemplate?.index || "",
index: settings?.index || "", service: this.connectInfo.settings.name,
service: this.connectInfo.settings.name, });
});
}
} }
getSettingsAsJson(configuration: string) { get isUpdateAvailable(): boolean {
try { return !!this.hecConfig;
return JSON.parse(configuration);
} catch {
return {};
}
} }
submit = async (): Promise<void> => { submit = async (): Promise<void> => {
@@ -62,7 +67,10 @@ export class ConnectHecDialogComponent implements OnInit {
const result: HecConnectDialogResult = { const result: HecConnectDialogResult = {
integrationSettings: this.connectInfo.settings, integrationSettings: this.connectInfo.settings,
configuration: JSON.stringify(formJson), url: formJson.url || "",
bearerToken: formJson.bearerToken || "",
index: formJson.index || "",
service: formJson.service || "",
success: true, success: true,
error: null, error: null,
}; };

View File

@@ -14,7 +14,6 @@
[externalURL]="integration.type === IntegrationType.SDK" [externalURL]="integration.type === IntegrationType.SDK"
[newBadgeExpiration]="integration.newBadgeExpiration" [newBadgeExpiration]="integration.newBadgeExpiration"
[description]="integration.description | i18n" [description]="integration.description | i18n"
[isConnected]="integration.isConnected"
[canSetupConnection]="integration.canSetupConnection" [canSetupConnection]="integration.canSetupConnection"
[integrationSettings]="integration" [integrationSettings]="integration"
></app-integration-card> ></app-integration-card>

View File

@@ -5,22 +5,17 @@ import { mock } from "jest-mock-extended";
import { of } from "rxjs"; import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
// eslint-disable-next-line no-restricted-imports import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { ThemeTypes } from "@bitwarden/common/platform/enums";
// eslint-disable-next-line import/order
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
// FIXME: remove `src` and fix import
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationCardComponent } from "../integration-card/integration-card.component"; import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
import { IntegrationGridComponent } from "./integration-grid.component"; import { IntegrationGridComponent } from "./integration-grid.component";
@@ -28,7 +23,7 @@ describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent; let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>; let fixture: ComponentFixture<IntegrationGridComponent>;
const mockActivatedRoute = mock<ActivatedRoute>(); const mockActivatedRoute = mock<ActivatedRoute>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>(); const mockIntegrationService = mock<HecOrganizationIntegrationService>();
const integrations: Integration[] = [ const integrations: Integration[] = [
{ {
name: "Integration 1", name: "Integration 1",
@@ -74,10 +69,7 @@ describe("IntegrationGridComponent", () => {
provide: ActivatedRoute, provide: ActivatedRoute,
useValue: mockActivatedRoute, useValue: mockActivatedRoute,
}, },
{ { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService },
provide: OrganizationIntegrationApiService,
useValue: mockOrgIntegrationApiService,
},
{ {
provide: ToastService, provide: ToastService,
useValue: mock<ToastService>(), useValue: mock<ToastService>(),

View File

@@ -1,12 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input } from "@angular/core"; import { Component, Input } from "@angular/core";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SharedModule } from "../../../../../../shared/shared.module";
import { IntegrationCardComponent } from "../integration-card/integration-card.component"; import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
@Component({ @Component({
selector: "app-integration-grid", selector: "app-integration-grid",
@@ -14,7 +12,7 @@ import { Integration } from "../models";
imports: [IntegrationCardComponent, SharedModule], imports: [IntegrationCardComponent, SharedModule],
}) })
export class IntegrationGridComponent { export class IntegrationGridComponent {
@Input() integrations: Integration[]; @Input() integrations: Integration[] = [];
@Input() ariaI18nKey: string = "integrationCardAriaLabel"; @Input() ariaI18nKey: string = "integrationCardAriaLabel";
@Input() tooltipI18nKey: string = "integrationCardTooltip"; @Input() tooltipI18nKey: string = "integrationCardTooltip";

View File

@@ -1,43 +1,34 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs"; import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs";
// eslint-disable-next-line no-restricted-imports import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { import {
getOrganizationById, getOrganizationById,
OrganizationService, OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { HeaderModule } from "../../../layouts/header/header.module"; import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
import { SharedModule } from "../../../shared/shared.module"; import { FilterIntegrationsPipe } from "./integrations.pipe";
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";
@Component({ @Component({
selector: "ac-integrations", selector: "ac-integrations",
templateUrl: "./integrations.component.html", templateUrl: "./integrations.component.html",
imports: [ imports: [SharedModule, IntegrationGridComponent, HeaderModule, FilterIntegrationsPipe],
SharedModule,
SharedOrganizationModule,
IntegrationGridComponent,
HeaderModule,
FilterIntegrationsPipe,
],
}) })
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
// integrationsList: Integration[] = []; tabIndex: number = 0;
tabIndex: number; organization$: Observable<Organization> = new Observable<Organization>();
organization$: Observable<Organization>;
isEventBasedIntegrationsEnabled: boolean = false; isEventBasedIntegrationsEnabled: boolean = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -218,39 +209,26 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
}, },
]; ];
ngOnInit(): void { async ngOnInit() {
const orgId = this.route.snapshot.params.organizationId; const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (!userId) {
throw new Error("User ID not found");
}
this.organization$ = this.route.params.pipe( this.organization$ = this.route.params.pipe(
switchMap((params) => switchMap((params) =>
this.accountService.activeAccount$.pipe( this.organizationService.organizations$(userId).pipe(
switchMap((account) => getOrganizationById(params.organizationId),
this.organizationService // Filter out undefined values
.organizations$(account?.id) takeWhile((org: Organization | undefined) => !!org),
.pipe(getOrganizationById(params.organizationId)),
),
), ),
), ),
); );
scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler) // Sets the organization ID which also loads the integrations$
.pipe(takeUntil(this.destroy$)) this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
.subscribe((integrations) => { this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id);
// 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);
if (existingIntegration) {
// if a configuration exists, then it is connected
existingIntegration.isConnected = !!integration.configuration;
existingIntegration.configuration = integration.configuration || "";
}
});
}
});
} }
constructor( constructor(
@@ -258,7 +236,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService, private organizationService: OrganizationService,
private accountService: AccountService, private accountService: AccountService,
private configService: ConfigService, private configService: ConfigService,
private orgIntegrationApiService: OrganizationIntegrationApiService, private hecOrganizationIntegrationService: HecOrganizationIntegrationService,
) { ) {
this.configService this.configService
.getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations) .getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations)
@@ -267,23 +245,40 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.isEventBasedIntegrationsEnabled = isEnabled; this.isEventBasedIntegrationsEnabled = isEnabled;
}); });
// Add the new event based items to the list
if (this.isEventBasedIntegrationsEnabled) { if (this.isEventBasedIntegrationsEnabled) {
this.integrationsList.push({ const crowdstrikeIntegration: Integration = {
name: "Crowdstrike", name: OrganizationIntegrationServiceType.CrowdStrike,
linkURL: "", linkURL: "",
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
type: IntegrationType.EVENT, type: IntegrationType.EVENT,
description: "crowdstrikeEventIntegrationDesc", description: "crowdstrikeEventIntegrationDesc",
isConnected: false, isConnected: false,
canSetupConnection: true, canSetupConnection: true,
}); };
this.integrationsList.push(crowdstrikeIntegration);
} }
// For all existing event based configurations loop through and assign the
// organizationIntegration for the correct services.
this.hecOrganizationIntegrationService.integrations$
.pipe(takeUntil(this.destroy$))
.subscribe((integrations) => {
integrations.map((integration) => {
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
if (item) {
item.organizationIntegration = integration;
}
});
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
} }
// use in the view
get IntegrationType(): typeof IntegrationType { get IntegrationType(): typeof IntegrationType {
return IntegrationType; return IntegrationType;
} }

View File

@@ -1,9 +1,8 @@
import { Pipe, PipeTransform } from "@angular/core"; import { Pipe, PipeTransform } from "@angular/core";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums";
import { Integration } from "../../../shared/components/integrations/models";
@Pipe({ @Pipe({
name: "filterIntegrations", name: "filterIntegrations",
}) })

View File

@@ -0,0 +1,23 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
const routes: Routes = [
{
path: "",
canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)],
component: AdminConsoleIntegrationsComponent,
data: {
titleId: "integrations",
},
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class OrganizationIntegrationsRoutingModule {}

View File

@@ -0,0 +1,32 @@
import { NgModule } from "@angular/core";
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 { ApiService } from "@bitwarden/common/abstractions/api.service";
import { safeProvider } from "@bitwarden/ui-common";
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module";
@NgModule({
imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule],
providers: [
safeProvider({
provide: HecOrganizationIntegrationService,
useClass: HecOrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({
provide: OrganizationIntegrationApiService,
useClass: OrganizationIntegrationApiService,
deps: [ApiService],
}),
safeProvider({
provide: OrganizationIntegrationConfigurationApiService,
useClass: OrganizationIntegrationConfigurationApiService,
deps: [ApiService],
}),
],
})
export class OrganizationIntegrationsModule {}

View File

@@ -9,14 +9,14 @@ import {} from "@bitwarden/web-vault/app/shared";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common"; import { I18nPipe } from "@bitwarden/ui-common";
import { IntegrationCardComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component";
import { IntegrationGridComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component"; import { IntegrationCardComponent } from "../../dirt/organization-integrations/integration-card/integration-card.component";
import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component";
import { IntegrationsComponent } from "./integrations.component"; import { IntegrationsComponent } from "./integrations.component";
@@ -36,8 +36,8 @@ class MockNewMenuComponent {}
describe("IntegrationsComponent", () => { describe("IntegrationsComponent", () => {
let fixture: ComponentFixture<IntegrationsComponent>; let fixture: ComponentFixture<IntegrationsComponent>;
const hecOrgIntegrationSvc = mock<HecOrganizationIntegrationService>();
const mockOrgIntegrationApiService = mock<OrganizationIntegrationApiService>();
const activatedRouteMock = { const activatedRouteMock = {
snapshot: { paramMap: { get: jest.fn() } }, snapshot: { paramMap: { get: jest.fn() } },
}; };
@@ -52,10 +52,9 @@ describe("IntegrationsComponent", () => {
{ provide: ThemeStateService, useValue: mock<ThemeStateService>() }, { provide: ThemeStateService, useValue: mock<ThemeStateService>() },
{ provide: SYSTEM_THEME_OBSERVABLE, useValue: of(ThemeType.Light) }, { provide: SYSTEM_THEME_OBSERVABLE, useValue: of(ThemeType.Light) },
{ provide: ActivatedRoute, useValue: activatedRouteMock }, { provide: ActivatedRoute, useValue: activatedRouteMock },
{ provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: I18nPipe, useValue: mock<I18nPipe>() }, { provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService }, { provide: I18nService, useValue: mockI18nService },
{ provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc },
], ],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(IntegrationsComponent); fixture = TestBed.createComponent(IntegrationsComponent);

View File

@@ -1,7 +1,7 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums"; import { IntegrationType } from "@bitwarden/common/enums";
import { Integration } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/models";
@Component({ @Component({
selector: "sm-integrations", selector: "sm-integrations",

View File

@@ -1,8 +1,7 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { IntegrationCardComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component"; import { IntegrationCardComponent } from "../../dirt/organization-integrations/integration-card/integration-card.component";
import { IntegrationGridComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component"; import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { IntegrationsRoutingModule } from "./integrations-routing.module"; import { IntegrationsRoutingModule } from "./integrations-routing.module";