1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

[PM-25931] Integrations - can save only if owner (#16570)

This commit is contained in:
Vijay Oommen
2025-09-29 08:27:21 -05:00
committed by GitHub
parent adbf80dd39
commit 90fb57817a
6 changed files with 232 additions and 101 deletions

View File

@@ -9759,6 +9759,9 @@
"failedToSaveIntegration": { "failedToSaveIntegration": {
"message": "Failed to save integration. Please try again later." "message": "Failed to save integration. Please try again later."
}, },
"mustBeOrgOwnerToPerformAction": {
"message": "You must be the organization owner to perform this action."
},
"failedToDeleteIntegration": { "failedToDeleteIntegration": {
"message": "Failed to delete integration. Please try again later." "message": "Failed to delete integration. Please try again later."
}, },

View File

@@ -1,5 +1,6 @@
import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs"; import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { import {
OrganizationId, OrganizationId,
OrganizationIntegrationId, OrganizationIntegrationId,
@@ -20,6 +21,11 @@ import { OrganizationIntegrationType } from "../models/organization-integration-
import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; import { OrganizationIntegrationApiService } from "./organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service";
export type HecModificationFailureReason = {
mustBeOwner: boolean;
success: boolean;
};
export class HecOrganizationIntegrationService { export class HecOrganizationIntegrationService {
private organizationId$ = new BehaviorSubject<OrganizationId | null>(null); private organizationId$ = new BehaviorSubject<OrganizationId | null>(null);
private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]); private _integrations$ = new BehaviorSubject<OrganizationIntegration[]>([]);
@@ -34,7 +40,7 @@ export class HecOrganizationIntegrationService {
const data$ = await this.setIntegrations(orgId); const data$ = await this.setIntegrations(orgId);
return await firstValueFrom(data$); return await firstValueFrom(data$);
} else { } else {
return this._integrations$.getValue(); return [] as OrganizationIntegration[];
} }
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@@ -56,6 +62,10 @@ export class HecOrganizationIntegrationService {
* @param orgId * @param orgId
*/ */
setOrganizationIntegrations(orgId: OrganizationId) { setOrganizationIntegrations(orgId: OrganizationId) {
if (orgId == this.organizationId$.getValue()) {
return;
}
this._integrations$.next([]);
this.organizationId$.next(orgId); this.organizationId$.next(orgId);
} }
@@ -73,31 +83,39 @@ export class HecOrganizationIntegrationService {
url: string, url: string,
bearerToken: string, bearerToken: string,
index: string, index: string,
) { ): Promise<HecModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) { if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch"); throw new Error("Organization ID mismatch");
} }
const hecConfig = new HecConfiguration(url, bearerToken, service); try {
const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( const hecConfig = new HecConfiguration(url, bearerToken, service);
organizationId, const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration(
new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()),
);
const newTemplate = new HecTemplate(index, service);
const newIntegrationConfigResponse =
await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
organizationId, organizationId,
newIntegrationResponse.id, new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()),
new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()),
); );
const newIntegration = this.mapResponsesToOrganizationIntegration( const newTemplate = new HecTemplate(index, service);
newIntegrationResponse, const newIntegrationConfigResponse =
newIntegrationConfigResponse, await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration(
); organizationId,
if (newIntegration !== null) { newIntegrationResponse.id,
this._integrations$.next([...this._integrations$.getValue(), newIntegration]); new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()),
);
const newIntegration = this.mapResponsesToOrganizationIntegration(
newIntegrationResponse,
newIntegrationConfigResponse,
);
if (newIntegration !== null) {
this._integrations$.next([...this._integrations$.getValue(), newIntegration]);
}
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
} }
} }
@@ -119,40 +137,48 @@ export class HecOrganizationIntegrationService {
url: string, url: string,
bearerToken: string, bearerToken: string,
index: string, index: string,
) { ): Promise<HecModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) { if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch"); throw new Error("Organization ID mismatch");
} }
const hecConfig = new HecConfiguration(url, bearerToken, service); try {
const updatedIntegrationResponse = const hecConfig = new HecConfiguration(url, bearerToken, service);
await this.integrationApiService.updateOrganizationIntegration( const updatedIntegrationResponse =
organizationId, await this.integrationApiService.updateOrganizationIntegration(
OrganizationIntegrationId, organizationId,
new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), 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,
); );
const updatedTemplate = new HecTemplate(index, service); if (updatedIntegration !== null) {
const updatedIntegrationConfigResponse = this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]);
await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( }
organizationId, return { mustBeOwner: false, success: true };
OrganizationIntegrationId, } catch (error) {
OrganizationIntegrationConfigurationId, if (error instanceof ErrorResponse && error.statusCode === 404) {
new OrganizationIntegrationConfigurationRequest( return { mustBeOwner: true, success: false };
null, }
null, throw error;
null,
updatedTemplate.toString(),
),
);
const updatedIntegration = this.mapResponsesToOrganizationIntegration(
updatedIntegrationResponse,
updatedIntegrationConfigResponse,
);
if (updatedIntegration !== null) {
this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]);
} }
} }
@@ -160,28 +186,38 @@ export class HecOrganizationIntegrationService {
organizationId: OrganizationId, organizationId: OrganizationId,
OrganizationIntegrationId: OrganizationIntegrationId, OrganizationIntegrationId: OrganizationIntegrationId,
OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId,
) { ): Promise<HecModificationFailureReason> {
if (organizationId != this.organizationId$.getValue()) { if (organizationId != this.organizationId$.getValue()) {
throw new Error("Organization ID mismatch"); throw new Error("Organization ID mismatch");
} }
// delete the configuration first due to foreign key constraint
await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
organizationId,
OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
);
// delete the integration try {
await this.integrationApiService.deleteOrganizationIntegration( // delete the configuration first due to foreign key constraint
organizationId, await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration(
OrganizationIntegrationId, organizationId,
); OrganizationIntegrationId,
OrganizationIntegrationConfigurationId,
);
// update the local observable // delete the integration
const updatedIntegrations = this._integrations$ await this.integrationApiService.deleteOrganizationIntegration(
.getValue() organizationId,
.filter((i) => i.id !== OrganizationIntegrationId); OrganizationIntegrationId,
this._integrations$.next(updatedIntegrations); );
// update the local observable
const updatedIntegrations = this._integrations$
.getValue()
.filter((i) => i.id !== OrganizationIntegrationId);
this._integrations$.next(updatedIntegrations);
return { mustBeOwner: false, success: true };
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return { mustBeOwner: true, success: false };
}
throw error;
}
} }
/** /**

View File

@@ -10,14 +10,18 @@ export class OrganizationIntegrationApiService {
async getOrganizationIntegrations( async getOrganizationIntegrations(
orgId: OrganizationId, orgId: OrganizationId,
): Promise<OrganizationIntegrationResponse[]> { ): Promise<OrganizationIntegrationResponse[]> {
const response = await this.apiService.send( try {
"GET", const response = await this.apiService.send(
`/organizations/${orgId}/integrations`, "GET",
null, `/organizations/${orgId}/integrations`,
true, null,
true, true,
); true,
return response; );
return response;
} catch {
return [];
}
} }
async createOrganizationIntegration( async createOrganizationIntegration(

View File

@@ -6,6 +6,7 @@ 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";
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; 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 { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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";
@@ -314,7 +315,7 @@ describe("IntegrationCardComponent", () => {
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false); jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false);
mockIntegrationService.saveHec.mockResolvedValue(undefined); mockIntegrationService.saveHec.mockResolvedValue({ mustBeOwner: false, success: true });
await component.setupConnection(); await component.setupConnection();
@@ -340,7 +341,7 @@ describe("IntegrationCardComponent", () => {
}), }),
}); });
mockIntegrationService.deleteHec.mockResolvedValue(undefined); mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true });
await component.setupConnection(); await component.setupConnection();
@@ -368,7 +369,7 @@ describe("IntegrationCardComponent", () => {
}), }),
}); });
mockIntegrationService.deleteHec.mockResolvedValue(undefined); mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true });
await component.setupConnection(); await component.setupConnection();
@@ -407,6 +408,52 @@ describe("IntegrationCardComponent", () => {
}); });
}); });
it("should show mustBeOwner toast on error while inserting data", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: mockI18nService.t("mustBeOrgOwnerToPerformAction"),
});
});
it("should show mustBeOwner toast on error while updating data", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Edited,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
await component.setupConnection();
expect(mockIntegrationService.updateHec).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: mockI18nService.t("mustBeOrgOwnerToPerformAction"),
});
});
it("should show toast on error while deleting", async () => { it("should show toast on error while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({ (openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({ closed: of({
@@ -429,5 +476,28 @@ describe("IntegrationCardComponent", () => {
message: mockI18nService.t("failedToDeleteIntegration"), message: mockI18nService.t("failedToDeleteIntegration"),
}); });
}); });
it("should show mustbeOwner toast on 404 while deleting", async () => {
(openHecConnectDialog as jest.Mock).mockReturnValue({
closed: of({
success: HecConnectDialogResultStatus.Delete,
url: "test-url",
bearerToken: "token",
index: "index",
}),
});
jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true);
mockIntegrationService.deleteHec.mockRejectedValue(new ErrorResponse("Not Found", 404));
await component.setupConnection();
expect(mockIntegrationService.deleteHec).toHaveBeenCalled();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "",
message: mockI18nService.t("mustBeOrgOwnerToPerformAction"),
});
});
}); });
}); });

View File

@@ -171,6 +171,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
} }
async saveHec(result: HecConnectDialogResult) { async saveHec(result: HecConnectDialogResult) {
let saveResponse = { mustBeOwner: false, success: false };
if (this.isUpdateAvailable) { if (this.isUpdateAvailable) {
// retrieve org integration and configuration ids // retrieve org integration and configuration ids
const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; const orgIntegrationId = this.integrationSettings.organizationIntegration?.id;
@@ -182,7 +183,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
} }
// update existing integration and configuration // update existing integration and configuration
await this.hecOrganizationIntegrationService.updateHec( saveResponse = await this.hecOrganizationIntegrationService.updateHec(
this.organizationId, this.organizationId,
orgIntegrationId, orgIntegrationId,
orgIntegrationConfigurationId, orgIntegrationConfigurationId,
@@ -193,7 +194,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
); );
} else { } else {
// create new integration and configuration // create new integration and configuration
await this.hecOrganizationIntegrationService.saveHec( saveResponse = await this.hecOrganizationIntegrationService.saveHec(
this.organizationId, this.organizationId,
this.integrationSettings.name as OrganizationIntegrationServiceType, this.integrationSettings.name as OrganizationIntegrationServiceType,
result.url, result.url,
@@ -201,6 +202,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
result.index, result.index,
); );
} }
if (saveResponse.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: "", title: "",
@@ -217,16 +224,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
throw Error("Organization Integration ID or Configuration ID is missing"); throw Error("Organization Integration ID or Configuration ID is missing");
} }
await this.hecOrganizationIntegrationService.deleteHec( const response = await this.hecOrganizationIntegrationService.deleteHec(
this.organizationId, this.organizationId,
orgIntegrationId, orgIntegrationId,
orgIntegrationConfigurationId, orgIntegrationConfigurationId,
); );
if (response.mustBeOwner) {
this.showMustBeOwnerToast();
return;
}
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: "", title: "",
message: this.i18nService.t("success"), message: this.i18nService.t("success"),
}); });
} }
private showMustBeOwnerToast() {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("mustBeOrgOwnerToPerformAction"),
});
}
} }

View File

@@ -5,16 +5,14 @@ import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile }
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { 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 { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
getOrganizationById,
OrganizationService,
} 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 { 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 { getById } from "@bitwarden/common/platform/misc";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { SharedModule } from "@bitwarden/web-vault/app/shared";
@@ -218,7 +216,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.organization$ = this.route.params.pipe( this.organization$ = this.route.params.pipe(
switchMap((params) => switchMap((params) =>
this.organizationService.organizations$(userId).pipe( this.organizationService.organizations$(userId).pipe(
getOrganizationById(params.organizationId), getById(params.organizationId),
// Filter out undefined values // Filter out undefined values
takeWhile((org: Organization | undefined) => !!org), takeWhile((org: Organization | undefined) => !!org),
), ),
@@ -229,6 +227,24 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => { this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id);
}); });
// 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) => {
// reset all integrations to null first - in case one was deleted
this.integrationsList.forEach((i) => {
i.organizationIntegration = null;
});
integrations.map((integration) => {
const item = this.integrationsList.find((i) => i.name === integration.serviceType);
if (item) {
item.organizationIntegration = integration;
}
});
});
} }
constructor( constructor(
@@ -258,24 +274,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
this.integrationsList.push(crowdstrikeIntegration); 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) => {
// reset all integrations to null first - in case one was deleted
this.integrationsList.forEach((i) => {
i.organizationIntegration = null;
});
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();