1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-27 06:43:41 +00:00

Merge branch 'main' into billing/pm-29600/update-tax-client

This commit is contained in:
Stephon Brown
2026-01-23 13:52:01 -06:00
27 changed files with 1221 additions and 255 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -10432,6 +10432,9 @@
"datadogEventIntegrationDesc": {
"message": "Send vault event data to your Datadog instance"
},
"huntressEventIntegrationDesc": {
"message": "Send event data to your Huntress SIEM instance"
},
"failedToSaveIntegration": {
"message": "Failed to save integration. Please try again later."
},
@@ -10543,6 +10546,12 @@
"index": {
"message": "Index"
},
"httpEventCollectorUrl": {
"message": "HTTP Event Collector URL"
},
"httpEventCollectorToken": {
"message": "HTTP Event Collector Token"
},
"selectAPlan": {
"message": "Select a plan"
},

View File

@@ -3,15 +3,21 @@ import { OrganizationIntegrationServiceName } from "../organization-integration-
export class HecConfiguration implements OrgIntegrationConfiguration {
uri: string;
scheme = "Bearer";
scheme: string;
token: string;
service?: string;
bw_serviceName: OrganizationIntegrationServiceName;
constructor(uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName) {
constructor(
uri: string,
token: string,
bw_serviceName: OrganizationIntegrationServiceName,
scheme: string = "Bearer",
) {
this.uri = uri;
this.token = token;
this.bw_serviceName = bw_serviceName;
this.scheme = scheme;
}
toString(): string {

View File

@@ -0,0 +1,338 @@
import { DatadogConfiguration } from "./configuration/datadog-configuration";
import { HecConfiguration } from "./configuration/hec-configuration";
import { OrgIntegrationBuilder } from "./integration-builder";
import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template";
import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template";
import { OrganizationIntegrationServiceName } from "./organization-integration-service-type";
import { OrganizationIntegrationType } from "./organization-integration-type";
describe("OrgIntegrationBuilder", () => {
describe("buildHecConfiguration", () => {
const testUri = "https://hec.example.com:8088/services/collector";
const testToken = "test-token";
it("should create HecConfiguration with correct values", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.Huntress,
);
expect(config).toBeInstanceOf(HecConfiguration);
expect((config as HecConfiguration).uri).toBe(testUri);
expect((config as HecConfiguration).token).toBe(testToken);
expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress);
});
it("should use default Bearer scheme", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.Huntress,
);
expect((config as HecConfiguration).scheme).toBe("Bearer");
});
it("should use custom scheme when provided", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.CrowdStrike,
"Splunk",
);
expect((config as HecConfiguration).scheme).toBe("Splunk");
});
it("should work with CrowdStrike service name", () => {
const config = OrgIntegrationBuilder.buildHecConfiguration(
testUri,
testToken,
OrganizationIntegrationServiceName.CrowdStrike,
);
expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.CrowdStrike);
});
});
describe("buildHecTemplate", () => {
it("should create HecTemplate with correct values", () => {
const template = OrgIntegrationBuilder.buildHecTemplate(
"main",
OrganizationIntegrationServiceName.Huntress,
);
expect(template).toBeInstanceOf(HecTemplate);
expect((template as HecTemplate).index).toBe("main");
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress);
});
it("should handle empty index", () => {
const template = OrgIntegrationBuilder.buildHecTemplate(
"",
OrganizationIntegrationServiceName.Huntress,
);
expect((template as HecTemplate).index).toBe("");
});
});
describe("buildDataDogConfiguration", () => {
const testUri = "https://http-intake.logs.datadoghq.com/api/v2/logs";
const testApiKey = "test-api-key";
it("should create DatadogConfiguration with correct values", () => {
const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey);
expect(config).toBeInstanceOf(DatadogConfiguration);
expect((config as DatadogConfiguration).uri).toBe(testUri);
expect((config as DatadogConfiguration).apiKey).toBe(testApiKey);
});
it("should always use Datadog service name", () => {
const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey);
expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog);
});
});
describe("buildDataDogTemplate", () => {
it("should create DatadogTemplate with correct service name", () => {
const template = OrgIntegrationBuilder.buildDataDogTemplate(
OrganizationIntegrationServiceName.Datadog,
);
expect(template).toBeInstanceOf(DatadogTemplate);
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog);
});
});
describe("buildConfiguration", () => {
describe("HEC type", () => {
it("should build HecConfiguration from JSON string", () => {
const json = JSON.stringify({
Uri: "https://hec.example.com",
Token: "test-token",
Scheme: "Bearer",
bw_serviceName: OrganizationIntegrationServiceName.Huntress,
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect(config).toBeInstanceOf(HecConfiguration);
expect((config as HecConfiguration).uri).toBe("https://hec.example.com");
expect((config as HecConfiguration).token).toBe("test-token");
expect((config as HecConfiguration).scheme).toBe("Bearer");
});
it("should normalize PascalCase properties to camelCase", () => {
const json = JSON.stringify({
Uri: "https://hec.example.com",
Token: "test-token",
Scheme: "Splunk",
bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike,
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect((config as HecConfiguration).uri).toBe("https://hec.example.com");
expect((config as HecConfiguration).token).toBe("test-token");
expect((config as HecConfiguration).scheme).toBe("Splunk");
});
});
describe("Datadog type", () => {
it("should build DatadogConfiguration from JSON string", () => {
const json = JSON.stringify({
Uri: "https://datadoghq.com/api",
ApiKey: "dd-api-key",
bw_serviceName: OrganizationIntegrationServiceName.Datadog,
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Datadog,
json,
);
expect(config).toBeInstanceOf(DatadogConfiguration);
expect((config as DatadogConfiguration).uri).toBe("https://datadoghq.com/api");
expect((config as DatadogConfiguration).apiKey).toBe("dd-api-key");
});
});
describe("error handling", () => {
it("should throw for unsupported integration type", () => {
const json = JSON.stringify({ uri: "test" });
expect(() =>
OrgIntegrationBuilder.buildConfiguration(999 as OrganizationIntegrationType, json),
).toThrow("Unsupported integration type: 999");
});
it("should throw for invalid JSON", () => {
expect(() =>
OrgIntegrationBuilder.buildConfiguration(OrganizationIntegrationType.Hec, "invalid-json"),
).toThrow("Invalid integration configuration: JSON parse error");
});
it("should handle empty JSON string by using empty object", () => {
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
"",
);
expect(config).toBeInstanceOf(HecConfiguration);
});
it("should handle undefined values in JSON", () => {
const json = JSON.stringify({});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect(config).toBeInstanceOf(HecConfiguration);
expect((config as HecConfiguration).uri).toBeUndefined();
});
});
});
describe("buildTemplate", () => {
describe("HEC type", () => {
it("should build HecTemplate from JSON string", () => {
const json = JSON.stringify({
index: "main",
bw_serviceName: OrganizationIntegrationServiceName.Huntress,
});
const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json);
expect(template).toBeInstanceOf(HecTemplate);
expect((template as HecTemplate).index).toBe("main");
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress);
});
it("should normalize PascalCase properties", () => {
const json = JSON.stringify({
Index: "security",
bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike,
});
const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json);
expect((template as HecTemplate).index).toBe("security");
});
});
describe("Datadog type", () => {
it("should build DatadogTemplate from JSON string", () => {
const json = JSON.stringify({
bw_serviceName: OrganizationIntegrationServiceName.Datadog,
});
const template = OrgIntegrationBuilder.buildTemplate(
OrganizationIntegrationType.Datadog,
json,
);
expect(template).toBeInstanceOf(DatadogTemplate);
expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog);
});
});
describe("error handling", () => {
it("should throw for unsupported integration type", () => {
const json = JSON.stringify({ index: "test" });
expect(() =>
OrgIntegrationBuilder.buildTemplate(999 as OrganizationIntegrationType, json),
).toThrow("Unsupported integration type: 999");
});
it("should throw for invalid JSON", () => {
expect(() =>
OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, "invalid-json"),
).toThrow("Invalid integration configuration: JSON parse error");
});
});
});
describe("property case normalization", () => {
it("should convert first character to lowercase", () => {
const json = JSON.stringify({
Uri: "https://example.com",
Token: "token",
Scheme: "Bearer",
bw_serviceName: "Huntress",
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
// Verify the properties were normalized (accessed via camelCase)
expect((config as HecConfiguration).uri).toBe("https://example.com");
expect((config as HecConfiguration).token).toBe("token");
});
it("should handle nested objects", () => {
// Using Datadog type which has nested enrichment_details
const json = JSON.stringify({
Uri: "https://datadoghq.com",
ApiKey: "key",
NestedObject: {
InnerProperty: "value",
},
});
// This tests that nested properties are also normalized
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Datadog,
json,
);
expect(config).toBeInstanceOf(DatadogConfiguration);
});
it("should handle arrays", () => {
const json = JSON.stringify({
Uri: "https://example.com",
Token: "token",
Items: [{ Name: "item1" }, { Name: "item2" }],
bw_serviceName: "Huntress",
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect(config).toBeInstanceOf(HecConfiguration);
});
it("should preserve properties that start with lowercase", () => {
const json = JSON.stringify({
uri: "https://example.com",
token: "token",
bw_serviceName: "Huntress",
});
const config = OrgIntegrationBuilder.buildConfiguration(
OrganizationIntegrationType.Hec,
json,
);
expect((config as HecConfiguration).uri).toBe("https://example.com");
expect((config as HecConfiguration).token).toBe("token");
});
});
});

View File

@@ -21,6 +21,11 @@ export interface OrgIntegrationTemplate {
toString(): string;
}
export const Schemas = {
Bearer: "Bearer",
Splunk: "Splunk",
} as const;
/**
* Builder class for creating organization integration configurations and templates
*/
@@ -29,8 +34,9 @@ export class OrgIntegrationBuilder {
uri: string,
token: string,
bw_serviceName: OrganizationIntegrationServiceName,
scheme: string = Schemas.Bearer,
): OrgIntegrationConfiguration {
return new HecConfiguration(uri, token, bw_serviceName);
return new HecConfiguration(uri, token, bw_serviceName, scheme);
}
static buildHecTemplate(
@@ -57,7 +63,12 @@ export class OrgIntegrationBuilder {
switch (type) {
case OrganizationIntegrationType.Hec: {
const hecConfig = this.convertToJson<HecConfiguration>(configuration);
return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.bw_serviceName);
return this.buildHecConfiguration(
hecConfig.uri,
hecConfig.token,
hecConfig.bw_serviceName,
hecConfig.scheme,
);
}
case OrganizationIntegrationType.Datadog: {
const datadogConfig = this.convertToJson<DatadogConfiguration>(configuration);

View File

@@ -2,8 +2,6 @@ import { OrgIntegrationTemplate } from "../../integration-builder";
import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type";
export class HecTemplate implements OrgIntegrationTemplate {
event = "#EventMessage#";
source = "Bitwarden";
index: string;
bw_serviceName: OrganizationIntegrationServiceName;
@@ -12,12 +10,46 @@ export class HecTemplate implements OrgIntegrationTemplate {
this.bw_serviceName = service;
}
toString(): string {
return JSON.stringify({
Event: this.event,
Source: this.source,
Index: this.index,
private toJSON() {
const template: Record<string, any> = {
bw_serviceName: this.bw_serviceName,
});
source: "bitwarden",
service: "event-logs",
event: {
object: "event",
type: "#Type#",
itemId: "#CipherId#",
collectionId: "#CollectionId#",
groupId: "#GroupId#",
policyId: "#PolicyId#",
memberId: "#UserId#",
actingUserId: "#ActingUserId#",
installationId: "#InstallationId#",
date: "#DateIso8601#",
device: "#DeviceType#",
ipAddress: "#IpAddress#",
secretId: "#SecretId#",
projectId: "#ProjectId#",
serviceAccountId: "#ServiceAccountId#",
actingUserName: "#ActingUserName#",
actingUserEmail: "#ActingUserEmail#",
actingUserType: "#ActingUserType#",
userName: "#UserName#",
userEmail: "#UserEmail#",
userType: "#UserType#",
groupName: "#GroupName#",
},
};
// Only include index if it's provided
if (this.index && this.index.trim() !== "") {
template.index = this.index;
}
return template;
}
toString(): string {
return JSON.stringify(this.toJSON());
}
}

View File

@@ -1,6 +1,7 @@
export const OrganizationIntegrationServiceName = Object.freeze({
CrowdStrike: "CrowdStrike",
Datadog: "Datadog",
Huntress: "Huntress",
} as const);
export type OrganizationIntegrationServiceName =

View File

@@ -16,13 +16,15 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog";
import { IntegrationDialogResultStatus, openHecConnectDialog } from "../integration-dialog";
import { IntegrationCardComponent } from "./integration-card.component";
jest.mock("../integration-dialog", () => ({
openHecConnectDialog: jest.fn(),
HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" },
openDatadogConnectDialog: jest.fn(),
openHuntressConnectDialog: jest.fn(),
IntegrationDialogResultStatus: { Edited: "edit", Delete: "delete" },
}));
describe("IntegrationCardComponent", () => {
@@ -276,7 +278,7 @@ describe("IntegrationCardComponent", () => {
it("should call updateHec if isUpdateAvailable is true", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -317,7 +319,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -354,7 +356,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -382,7 +384,7 @@ describe("IntegrationCardComponent", () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -404,7 +406,7 @@ describe("IntegrationCardComponent", () => {
it("should show toast on error while saving", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -427,7 +429,7 @@ describe("IntegrationCardComponent", () => {
it("should show mustBeOwner toast on error while inserting data", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -450,7 +452,7 @@ describe("IntegrationCardComponent", () => {
it("should show mustBeOwner toast on error while updating data", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -472,7 +474,7 @@ describe("IntegrationCardComponent", () => {
it("should show toast on error while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
@@ -495,7 +497,7 @@ describe("IntegrationCardComponent", () => {
it("should show mustbeOwner toast on 404 while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
success: IntegrationDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",

View File

@@ -12,7 +12,12 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
import {
OrgIntegrationBuilder,
OrgIntegrationConfiguration,
OrgIntegrationTemplate,
Schemas,
} from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder";
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
@@ -23,7 +28,6 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import {
BaseCardComponent,
CardContentComponent,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
@@ -32,10 +36,11 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
HecConnectDialogResult,
DatadogConnectDialogResult,
HecConnectDialogResultStatus,
DatadogConnectDialogResultStatus,
HuntressConnectDialogResult,
IntegrationDialogResultStatus,
openDatadogConnectDialog,
openHecConnectDialog,
openHuntressConnectDialog,
} from "../integration-dialog/index";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -164,14 +169,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
async setupConnection() {
let dialog: DialogRef<DatadogConnectDialogResult | HecConnectDialogResult, unknown>;
if (this.integrationSettings?.integrationType === null) {
return;
}
if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) {
dialog = openDatadogConnectDialog(this.dialogService, {
const dialog = openDatadogConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
@@ -179,37 +182,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
}
await this.handleIntegrationDialogResult(
result,
() => this.deleteDatadog(),
(res) => this.saveDatadog(res),
);
} else if (this.integrationSettings.name === OrganizationIntegrationServiceName.Huntress) {
// Huntress uses HEC protocol but has its own dialog
const dialog = openHuntressConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
});
try {
if (result.success === HecConnectDialogResultStatus.Delete) {
await this.deleteDatadog();
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("failedToDeleteIntegration"),
});
}
const result = await lastValueFrom(dialog.closed);
try {
if (result.success === DatadogConnectDialogResultStatus.Edited) {
await this.saveDatadog(result as DatadogConnectDialogResult);
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("failedToSaveIntegration"),
});
}
await this.handleIntegrationDialogResult(
result,
() => this.deleteHuntress(),
(res) => this.saveHuntress(res),
);
} else {
// invoke the dialog to connect the integration
dialog = openHecConnectDialog(this.dialogService, {
const dialog = openHecConnectDialog(this.dialogService, {
data: {
settings: this.integrationSettings,
},
@@ -217,15 +212,113 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
const result = await lastValueFrom(dialog.closed);
// the dialog was cancelled
if (!result || !result.success) {
return;
await this.handleIntegrationDialogResult(
result,
() => this.deleteHec(),
(res) => this.saveHec(res),
);
}
}
/**
* Generic save method
*/
private async saveIntegration(
integrationType: OrganizationIntegrationType,
config: OrgIntegrationConfiguration,
template: OrgIntegrationTemplate,
): Promise<void> {
let response = { mustBeOwner: false, success: false };
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
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");
}
// update existing integration and configuration
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
integrationType,
orgIntegrationConfigurationId,
config,
template,
);
} else {
// create new integration and configuration
response = await this.organizationIntegrationService.save(
this.organizationId,
integrationType,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
}
/**
* Generic delete method
*/
private async deleteIntegration(): Promise<void> {
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");
}
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
);
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
}
/**
* Generic dialog result handler
* Handles both delete and edit actions with proper error handling
*/
private async handleIntegrationDialogResult<T extends { success: string | null }>(
result: T | undefined,
deleteCallback: () => Promise<void>,
saveCallback: (result: T) => Promise<void>,
): Promise<void> {
// User cancelled the dialog or closed it without saving
if (!result || !result.success) {
return;
}
// Handle delete action
if (result.success === IntegrationDialogResultStatus.Delete) {
try {
if (result.success === HecConnectDialogResultStatus.Delete) {
await this.deleteHec();
}
await deleteCallback();
} catch {
this.toastService.showToast({
variant: "error",
@@ -233,11 +326,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
message: this.i18nService.t("failedToDeleteIntegration"),
});
}
return;
}
// Handle edit/save action
if (result.success === IntegrationDialogResultStatus.Edited) {
try {
if (result.success === HecConnectDialogResultStatus.Edited) {
await this.saveHec(result as HecConnectDialogResult);
}
await saveCallback(result);
} catch {
this.toastService.showToast({
variant: "error",
@@ -249,8 +344,6 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
}
async saveHec(result: HecConnectDialogResult) {
let response = { mustBeOwner: false, success: false };
const config = OrgIntegrationBuilder.buildHecConfiguration(
result.url,
result.bearerToken,
@@ -261,148 +354,45 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
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");
}
// update existing integration and configuration
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
OrganizationIntegrationType.Hec,
orgIntegrationConfigurationId,
config,
template,
);
} else {
// create new integration and configuration
response = await this.organizationIntegrationService.save(
this.organizationId,
OrganizationIntegrationType.Hec,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
await this.saveIntegration(OrganizationIntegrationType.Hec, config, template);
}
async deleteHec() {
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
await this.deleteIntegration();
}
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
async saveHuntress(result: HuntressConnectDialogResult) {
// Huntress uses "Splunk" scheme for HEC protocol compatibility
const config = OrgIntegrationBuilder.buildHecConfiguration(
result.url,
result.token,
OrganizationIntegrationServiceName.Huntress,
Schemas.Splunk,
);
// Huntress SIEM doesn't require the index field
const template = OrgIntegrationBuilder.buildHecTemplate(
"",
OrganizationIntegrationServiceName.Huntress,
);
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
await this.saveIntegration(OrganizationIntegrationType.Hec, config, template);
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
async deleteHuntress() {
await this.deleteIntegration();
}
async saveDatadog(result: DatadogConnectDialogResult) {
let response = { mustBeOwner: false, success: false };
const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey);
const template = OrgIntegrationBuilder.buildDataDogTemplate(
this.integrationSettings.name as OrganizationIntegrationServiceName,
);
if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
const orgIntegrationConfigurationId =
this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id;
if (!orgIntegrationId || !orgIntegrationConfigurationId) {
throw Error("Organization Integration ID or Configuration ID is missing");
}
// update existing integration and configuration
response = await this.organizationIntegrationService.update(
this.organizationId,
orgIntegrationId,
OrganizationIntegrationType.Datadog,
orgIntegrationConfigurationId,
config,
template,
);
} else {
// create new integration and configuration
response = await this.organizationIntegrationService.save(
this.organizationId,
OrganizationIntegrationType.Datadog,
config,
template,
);
}
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
await this.saveIntegration(OrganizationIntegrationType.Datadog, config, template);
}
async deleteDatadog() {
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");
}
const response = await this.organizationIntegrationService.delete(
this.organizationId,
orgIntegrationId,
orgIntegrationConfigurationId,
);
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("success"),
});
await this.deleteIntegration();
}
private showMustBeOwnerToast() {

View File

@@ -23,7 +23,7 @@
<bit-form-field>
<bit-label>{{ "apiKey" | i18n }}</bit-label>
<input bitInput type="text" formControlName="apiKey" />
<input bitInput type="password" formControlName="apiKey" />
<bit-hint>{{ "apiKey" | i18n }}</bit-hint>
</bit-form-field>
</ng-container>

View File

@@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationDialogResultStatus } from "../integration-dialog-result-status";
import {
ConnectDatadogDialogComponent,
DatadogConnectDialogParams,
DatadogConnectDialogResult,
DatadogConnectDialogResultStatus,
openDatadogConnectDialog,
} from "./connect-dialog-datadog.component";
@@ -149,7 +150,7 @@ describe("ConnectDialogDatadogComponent", () => {
url: "https://test.com",
apiKey: "token",
service: "Test Service",
success: DatadogConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
});
});
});

View File

@@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
IntegrationDialogResultStatus,
IntegrationDialogResultStatusType,
} from "../integration-dialog-result-status";
export type DatadogConnectDialogParams = {
settings: Integration;
};
@@ -16,17 +21,9 @@ export interface DatadogConnectDialogResult {
url: string;
apiKey: string;
service: string;
success: DatadogConnectDialogResultStatusType | null;
success: IntegrationDialogResultStatusType | null;
}
export const DatadogConnectDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type DatadogConnectDialogResultStatusType =
(typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -78,7 +75,7 @@ export class ConnectDatadogDialogComponent implements OnInit {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited);
const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Edited);
this.dialogRef.close(result);
@@ -95,13 +92,13 @@ export class ConnectDatadogDialogComponent implements OnInit {
});
if (confirmed) {
const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete);
const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getDatadogConnectDialogResult(
status: DatadogConnectDialogResultStatusType,
status: IntegrationDialogResultStatusType,
): DatadogConnectDialogResult {
const formJson = this.formGroup.getRawValue();

View File

@@ -23,7 +23,7 @@
<bit-form-field>
<bit-label>{{ "bearerToken" | i18n }}</bit-label>
<input bitInput type="text" formControlName="bearerToken" />
<input bitInput type="password" formControlName="bearerToken" />
<bit-hint>{{ "apiKey" | i18n }}</bit-hint>
</bit-form-field>

View File

@@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationDialogResultStatus } from "../integration-dialog-result-status";
import {
ConnectHecDialogComponent,
HecConnectDialogParams,
HecConnectDialogResult,
HecConnectDialogResultStatus,
openHecConnectDialog,
} from "./connect-dialog-hec.component";
@@ -155,7 +156,7 @@ describe("ConnectDialogHecComponent", () => {
bearerToken: "token",
index: "1",
service: "Test Service",
success: HecConnectDialogResultStatus.Edited,
success: IntegrationDialogResultStatus.Edited,
});
});
});

View File

@@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
IntegrationDialogResultStatus,
IntegrationDialogResultStatusType,
} from "../integration-dialog-result-status";
export type HecConnectDialogParams = {
settings: Integration;
};
@@ -17,17 +22,9 @@ export interface HecConnectDialogResult {
bearerToken: string;
index: string;
service: string;
success: HecConnectDialogResultStatusType | null;
success: IntegrationDialogResultStatusType | null;
}
export const HecConnectDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type HecConnectDialogResultStatusType =
(typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -81,7 +78,7 @@ export class ConnectHecDialogComponent implements OnInit {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited);
const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Edited);
this.dialogRef.close(result);
@@ -98,13 +95,13 @@ export class ConnectHecDialogComponent implements OnInit {
});
if (confirmed) {
const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete);
const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getHecConnectDialogResult(
status: HecConnectDialogResultStatusType,
status: IntegrationDialogResultStatusType,
): HecConnectDialogResult {
const formJson = this.formGroup.getRawValue();

View File

@@ -0,0 +1,57 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }}
</span>
<div bitDialogContent class="tw-flex tw-flex-col tw-gap-4">
@if (loading) {
<ng-container #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
}
@if (!loading) {
<ng-container>
<bit-form-field>
<bit-label>{{ "httpEventCollectorUrl" | i18n }}</bit-label>
<input
bitInput
type="text"
formControlName="url"
placeholder="https://hec.huntress.io/services/collector"
/>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "httpEventCollectorToken" | i18n }}</bit-label>
<input bitInput type="password" formControlName="token" />
</bit-form-field>
</ng-container>
}
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
@if (isUpdateAvailable) {
{{ "update" | i18n }}
} @else {
{{ "save" | i18n }}
}
</button>
<button type="button" bitButton bitDialogClose buttonType="secondary" [disabled]="loading">
{{ "cancel" | i18n }}
</button>
@if (canDelete) {
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
label="{{ 'delete' | i18n }}"
appA11yTitle="{{ 'delete' | i18n }}"
[bitAction]="delete"
></button>
</div>
}
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,206 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { IntegrationDialogResultStatus } from "../integration-dialog-result-status";
import {
ConnectHuntressDialogComponent,
HuntressConnectDialogParams,
HuntressConnectDialogResult,
openHuntressConnectDialog,
} from "./connect-dialog-huntress.component";
beforeAll(() => {
// Mock element.animate for jsdom
// the animate function is not available in jsdom, so we provide a mock implementation
// This is necessary for tests that rely on animations
// This mock does not perform any actual animations, it just provides a structure that allows tests
// to run without throwing errors related to missing animate function
if (!HTMLElement.prototype.animate) {
HTMLElement.prototype.animate = function () {
return {
play: () => {},
pause: () => {},
finish: () => {},
cancel: () => {},
reverse: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onfinish: null,
oncancel: null,
startTime: 0,
currentTime: 0,
playbackRate: 1,
playState: "idle",
replaceState: "active",
effect: null,
finished: Promise.resolve(),
id: "",
remove: () => {},
timeline: null,
ready: Promise.resolve(),
} as unknown as Animation;
};
}
});
describe("ConnectHuntressDialogComponent", () => {
let component: ConnectHuntressDialogComponent;
let fixture: ComponentFixture<ConnectHuntressDialogComponent>;
let dialogRefMock = mock<DialogRef<HuntressConnectDialogResult>>();
const mockI18nService = mock<I18nService>();
const integrationMock: Integration = {
name: "Huntress",
image: "test-image.png",
linkURL: "https://example.com",
imageDarkMode: "test-image-dark.png",
newBadgeExpiration: "2024-12-31",
description: "Test Description",
canSetupConnection: true,
type: IntegrationType.EVENT,
} as Integration;
const connectInfo: HuntressConnectDialogParams = {
settings: integrationMock,
};
beforeEach(async () => {
dialogRefMock = mock<DialogRef<HuntressConnectDialogResult>>();
await TestBed.configureTestingModule({
imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule],
providers: [
FormBuilder,
{ provide: DIALOG_DATA, useValue: connectInfo },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: I18nPipe, useValue: mock<I18nPipe>() },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ConnectHuntressDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
mockI18nService.t.mockImplementation((key) => key);
});
it("should create the component", () => {
expect(component).toBeTruthy();
});
it("should initialize form with empty values and service name", () => {
expect(component.formGroup.value).toEqual({
url: "",
token: "",
service: "Huntress",
});
});
it("should have required validators for url and token fields", () => {
component.formGroup.setValue({ url: "", token: "", service: "" });
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://hec.huntress.io/services/collector",
token: "test-token",
service: "Huntress",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should require url to be at least 7 characters long", () => {
component.formGroup.setValue({
url: "test",
token: "token",
service: "Huntress",
});
expect(component.formGroup.valid).toBeFalsy();
component.formGroup.setValue({
url: "https://hec.huntress.io",
token: "token",
service: "Huntress",
});
expect(component.formGroup.valid).toBeTruthy();
});
it("should call dialogRef.close with correct result on submit", async () => {
component.formGroup.setValue({
url: "https://hec.huntress.io/services/collector",
token: "test-token",
service: "Huntress",
});
await component.submit();
expect(dialogRefMock.close).toHaveBeenCalledWith({
integrationSettings: integrationMock,
url: "https://hec.huntress.io/services/collector",
token: "test-token",
service: "Huntress",
success: IntegrationDialogResultStatus.Edited,
});
});
it("should not submit when form is invalid", async () => {
component.formGroup.setValue({
url: "",
token: "",
service: "Huntress",
});
await component.submit();
expect(dialogRefMock.close).not.toHaveBeenCalled();
expect(component.formGroup.touched).toBeTruthy();
});
it("should return false for isUpdateAvailable when no config exists", () => {
component.huntressConfig = null;
expect(component.isUpdateAvailable).toBeFalsy();
});
it("should return true for isUpdateAvailable when config exists", () => {
component.huntressConfig = { uri: "test", token: "test" } as any;
expect(component.isUpdateAvailable).toBeTruthy();
});
it("should return false for canDelete when no config exists", () => {
component.huntressConfig = null;
expect(component.canDelete).toBeFalsy();
});
it("should return true for canDelete when config exists", () => {
component.huntressConfig = { uri: "test", token: "test" } as any;
expect(component.canDelete).toBeTruthy();
});
});
describe("openHuntressConnectDialog", () => {
it("should call dialogService.open with correct params", () => {
const dialogServiceMock = mock<DialogService>();
const config: DialogConfig<
HuntressConnectDialogParams,
DialogRef<HuntressConnectDialogResult>
> = {
data: { settings: { name: "Huntress" } as Integration },
} as any;
openHuntressConnectDialog(dialogServiceMock, config);
expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHuntressDialogComponent, config);
});
});

View File

@@ -0,0 +1,114 @@
import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core";
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 { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
IntegrationDialogResultStatus,
IntegrationDialogResultStatusType,
} from "../integration-dialog-result-status";
export type HuntressConnectDialogParams = {
settings: Integration;
};
export interface HuntressConnectDialogResult {
integrationSettings: Integration;
url: string;
token: string;
service: string;
success: IntegrationDialogResultStatusType | null;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./connect-dialog-huntress.component.html",
imports: [SharedModule],
})
export class ConnectHuntressDialogComponent implements OnInit {
loading = false;
huntressConfig: HecConfiguration | null = null;
formGroup = this.formBuilder.group({
url: ["", [Validators.required, Validators.minLength(7)]],
token: ["", Validators.required],
service: [""], // Programmatically set in ngOnInit, not shown to user
});
constructor(
@Inject(DIALOG_DATA) protected connectInfo: HuntressConnectDialogParams,
protected formBuilder: FormBuilder,
private dialogRef: DialogRef<HuntressConnectDialogResult>,
private dialogService: DialogService,
) {}
ngOnInit(): void {
this.huntressConfig =
this.connectInfo.settings.organizationIntegration?.getConfiguration<HecConfiguration>() ??
null;
this.formGroup.patchValue({
url: this.huntressConfig?.uri || "",
token: this.huntressConfig?.token || "",
service: this.connectInfo.settings.name,
});
}
get isUpdateAvailable(): boolean {
return !!this.huntressConfig;
}
get canDelete(): boolean {
return !!this.huntressConfig;
}
submit = async (): Promise<void> => {
if (this.formGroup.invalid) {
this.formGroup.markAllAsTouched();
return;
}
const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Edited);
this.dialogRef.close(result);
return;
};
delete = async (): Promise<void> => {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: "deleteItemConfirmation",
},
type: "warning",
});
if (confirmed) {
const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Delete);
this.dialogRef.close(result);
}
};
private getHuntressConnectDialogResult(
status: IntegrationDialogResultStatusType,
): HuntressConnectDialogResult {
const formJson = this.formGroup.getRawValue();
return {
integrationSettings: this.connectInfo.settings,
url: formJson.url || "",
token: formJson.token || "",
service: formJson.service || "",
success: status,
};
}
}
export function openHuntressConnectDialog(
dialogService: DialogService,
config: DialogConfig<HuntressConnectDialogParams, DialogRef<HuntressConnectDialogResult>>,
) {
return dialogService.open<HuntressConnectDialogResult>(ConnectHuntressDialogComponent, config);
}

View File

@@ -1,2 +1,4 @@
export * from "./connect-dialog/connect-dialog-hec.component";
export * from "./connect-dialog/connect-dialog-datadog.component";
export * from "./connect-dialog/connect-dialog-huntress.component";
export * from "./integration-dialog-result-status";

View File

@@ -0,0 +1,11 @@
/**
* Shared status types for integration dialog results
* Used across all SIEM integration dialogs (HEC, Datadog, Huntress, etc.)
*/
export const IntegrationDialogResultStatus = {
Edited: "edit",
Delete: "delete",
} as const;
export type IntegrationDialogResultStatusType =
(typeof IntegrationDialogResultStatus)[keyof typeof IntegrationDialogResultStatus];

View File

@@ -32,6 +32,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
tabIndex: number = 0;
organization$: Observable<Organization> = new Observable<Organization>();
isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false;
isEventManagementForHuntressEnabled: boolean = false;
private destroy$ = new Subject<void>();
// initialize the integrations list with default integrations
@@ -258,6 +259,13 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled;
});
this.configService
.getFeatureFlag$(FeatureFlag.EventManagementForHuntress)
.pipe(takeUntil(this.destroy$))
.subscribe((isEnabled) => {
this.isEventManagementForHuntressEnabled = isEnabled;
});
// Add the new event based items to the list
if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
const crowdstrikeIntegration: Integration = {
@@ -285,6 +293,21 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.integrationsList.push(datadogIntegration);
}
// Add Huntress SIEM integration (separate feature flag)
if (this.isEventManagementForHuntressEnabled) {
const huntressIntegration: Integration = {
name: OrganizationIntegrationServiceName.Huntress,
linkURL: "https://bitwarden.com/help/huntress-siem/",
image: "../../../../../../../images/integrations/logo-huntress-siem.svg",
type: IntegrationType.EVENT,
description: "huntressEventIntegrationDesc",
canSetupConnection: true,
integrationType: OrganizationIntegrationType.Hec,
};
this.integrationsList.push(huntressIntegration);
}
// For all existing event based configurations loop through and assign the
// organizationIntegration for the correct services.
this.organizationIntegrationService.integrations$

View File

@@ -56,6 +56,7 @@ export enum FeatureFlag {
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
EventManagementForHuntress = "event-management-for-huntress",
PhishingDetection = "phishing-detection",
/* Vault */
@@ -119,6 +120,7 @@ export const DefaultFeatureFlagValue = {
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
[FeatureFlag.EventManagementForHuntress]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
/* Vault */

View File

@@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => {
});
describe("getVaultTimeoutByUserId$", () => {
beforeEach(() => {
// Return the input value unchanged
sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
async (timeout) => timeout,
);
});
it("should throw an error if no user id is provided", async () => {
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
"User id required. Cannot get vault timeout.",
@@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
defaultVaultTimeout,
);
expect(result).toBe(defaultVaultTimeout);
});
@@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutNumberType.OnMinute;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(of([]));
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: custom", () => {
@@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(policyMinutes);
},
);
@@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
vaultTimeout,
);
expect(result).toBe(vaultTimeout);
},
);
@@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(VaultTimeoutNumberType.Immediately);
});
it("promotes policy minutes when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutNumberType.EightHours,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
policyMinutes,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: immediately", () => {
@@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns immediately or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutNumberType.Immediately;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
@@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => {
expect(result).toBe(expectedTimeout);
},
);
it("promotes immediately when unavailable on client", async () => {
const promotedValue = VaultTimeoutNumberType.OnMinute;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutNumberType.Immediately,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onSystemLock", () => {
@@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => {
"when current timeout is %s, returns onLocked or promoted value",
async (currentTimeout) => {
const expectedTimeout = VaultTimeoutStringType.OnLocked;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
@@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes onLocked when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnLocked,
);
expect(result).toBe(promotedValue);
});
});
describe("policy type: onAppRestart", () => {
@@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(VaultTimeoutStringType.OnRestart);
});
@@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
});
describe("policy type: never", () => {
it("when current timeout is never, returns never or promoted value", async () => {
const expectedTimeout = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
it("promotes onRestart when unavailable on client", async () => {
const promotedValue = VaultTimeoutStringType.Never;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
await stateProvider.setUserState(
VAULT_TIMEOUT,
VaultTimeoutStringType.OnLocked,
mockUserId,
);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
);
expect(result).toBe(expectedTimeout);
expect(result).toBe(promotedValue);
});
});
describe("policy type: never", () => {
it.each([
VaultTimeoutStringType.Never,
VaultTimeoutStringType.OnRestart,
VaultTimeoutStringType.OnLocked,
VaultTimeoutStringType.OnIdle,
@@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => {
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
currentTimeout,
);
expect(result).toBe(currentTimeout);
});
it("promotes timeout when unavailable on client", async () => {
const determinedTimeout = VaultTimeoutStringType.Never;
const promotedValue = VaultTimeoutStringType.OnRestart;
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
policyService.policiesByType$.mockReturnValue(
of([{ data: { type: "never" } }] as unknown as Policy[]),
);
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
const result = await firstValueFrom(
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
);
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
determinedTimeout,
);
expect(result).toBe(promotedValue);
});
});
});

View File

@@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
private async determineVaultTimeout(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout | null> {
): Promise<VaultTimeout> {
const determinedTimeout = await this.determineVaultTimeoutInternal(
currentVaultTimeout,
maxSessionTimeoutPolicyData,
);
// Ensures the timeout is available on this client
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout);
}
private async determineVaultTimeoutInternal(
currentVaultTimeout: VaultTimeout | null,
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
): Promise<VaultTimeout> {
// if current vault timeout is null, apply the client specific default
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
@@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
switch (maxSessionTimeoutPolicyData.type) {
case "immediately":
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutNumberType.Immediately,
);
return VaultTimeoutNumberType.Immediately;
case "custom":
case null:
case undefined:
@@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.OnLocked,
);
return VaultTimeoutStringType.OnLocked;
}
break;
case "onAppRestart":
@@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
break;
case "never":
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
VaultTimeoutStringType.Never,
);
}
// Policy doesn't override user preference for "never"
break;
}
return currentVaultTimeout;

View File

@@ -27,7 +27,7 @@
<!-- 53rem = ~850px -->
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
<div
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full"
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
>
<bit-nav-divider></bit-nav-divider>
@if (data.open) {

View File

@@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
@@ -259,7 +260,18 @@ describe("keyService", () => {
});
});
it("clears the Auto key if vault timeout is set to anything other than null", async () => {
it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => {
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
platformUtilService.getClientType.mockReturnValue(ClientType.Cli);
await keyService.setUserKey(mockUserKey, mockUserId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, {
userId: mockUserId,
});
});
it("clears the Auto key if vault timeout is set to 10 minutes", async () => {
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
await keyService.setUserKey(mockUserKey, mockUserId);

View File

@@ -14,6 +14,7 @@ import {
switchMap,
} from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key";
import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
@@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
}
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) {
let shouldStoreKey = false;
switch (keySuffix) {
case KeySuffixOptions.Auto: {
// Cli has fixed Never vault timeout, and it should not be affected by a policy.
if (this.platformUtilService.getClientType() == ClientType.Cli) {
return true;
}
// TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between
// the VaultTimeoutSettingsSvc and this service.
// This should be fixed as part of the PM-7082 - Auto Key Service work.
@@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction {
.pipe(filter((timeout) => timeout != null)),
);
shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never;
break;
this.logService.debug(
`[KeyService] Should store auto key for vault timeout ${vaultTimeout}`,
);
return vaultTimeout == VaultTimeoutStringType.Never;
}
}
return shouldStoreKey;
return false;
}
protected async getKeyFromStorage(