diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts new file mode 100644 index 000000000000..68d53fd54e05 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts @@ -0,0 +1,13 @@ +import { AccessReportMetricsApi } from "./access-report-metrics.api"; + +/** + * Request body for POST /reports/organizations/{organizationId} + * + */ +export class AccessReportCreateApi { + contentEncryptionKey?: string; + summaryData?: string; + applicationData?: string; + metrics?: AccessReportMetricsApi; + fileSize?: number; +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts new file mode 100644 index 000000000000..9c782164682e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts @@ -0,0 +1,29 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { FileUploadType } from "@bitwarden/common/platform/enums"; + +import { AccessReportApi } from "./access-report.api"; + +/** + * Response model returned when creating a report with the Access Intelligence V2 feature flag + * enabled. Contains a presigned upload URL for the report file along with the created report. + * + * - See {@link AccessReportApi} for the nested report response model + */ +export class AccessReportFileApi extends BaseResponse { + reportFileUploadUrl: string = ""; + reportResponse: AccessReportApi = new AccessReportApi(); + fileUploadType: FileUploadType = FileUploadType.Direct; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.reportFileUploadUrl = this.getResponseProperty("reportFileUploadUrl") ?? ""; + const reportResponse = this.getResponseProperty("reportResponse"); + this.reportResponse = + reportResponse != null ? new AccessReportApi(reportResponse) : new AccessReportApi(); + this.fileUploadType = this.getResponseProperty("fileUploadType") ?? FileUploadType.Direct; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts index 0470f4331647..54924a1d0541 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report.api.ts @@ -7,6 +7,8 @@ import { AccessReport } from "../domain/access-report"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { AccessReportView } from "../view/access-report.view"; +import { ReportFileApi } from "./report-file.api"; + /** * Converts an AccessReport API response * @@ -24,6 +26,8 @@ export class AccessReportApi extends BaseResponse { memberRegistry: string = ""; creationDate: string = ""; contentEncryptionKey: string = ""; + reportFile?: ReportFileApi; + reportFileDownloadUrl?: string; constructor(data: any = null) { super(data); @@ -39,6 +43,10 @@ export class AccessReportApi extends BaseResponse { this.summary = this.getResponseProperty("summaryData"); this.memberRegistry = this.getResponseProperty("memberRegistry") ?? ""; this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); + this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; + + const reportFile = this.getResponseProperty("reportFile"); + this.reportFile = reportFile != null ? new ReportFileApi(reportFile) : undefined; // Use when individual values are encrypted // const summary = this.getResponseProperty("summaryData"); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts new file mode 100644 index 000000000000..62e5acb122cc --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts @@ -0,0 +1,20 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +/** + * Metadata for an uploaded report file. + */ +export class ReportFileApi extends BaseResponse { + id: string | undefined; + fileName: string = ""; + /** File size in bytes. Serialized as a string by the server. */ + size: number = 0; + validated: boolean = false; + + constructor(data: any) { + super(data); + this.id = this.getResponseProperty("id") ?? undefined; + this.fileName = this.getResponseProperty("fileName") ?? ""; + this.size = Number(this.getResponseProperty("size") ?? 0); + this.validated = this.getResponseProperty("validated") ?? false; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts index 46f30be4848e..2e3333dcc31d 100644 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/index.ts @@ -4,6 +4,7 @@ export * from "./api/application-health.api"; export * from "./api/access-report-settings.api"; export * from "./api/access-report-summary.api"; export * from "./api/access-report-metrics.api"; +export * from "./api/access-report-file.api"; // Data layer export * from "./data/access-report.data"; diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts new file mode 100644 index 000000000000..24033759048a --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -0,0 +1,151 @@ +import { Observable } from "rxjs"; + +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; + +import { + AccessReportApi, + AccessReportFileApi, + AccessReportMetricsApi, + AccessReportSummaryApi, +} from "../../models"; + +export interface AccessReportCreateRequest { + reportData?: string; + contentEncryptionKey?: string; + summaryData?: string; + applicationData?: string; + metrics?: AccessReportMetricsApi; + fileSize?: number; +} + +export interface AccessReportUpdateRequest { + reportData?: string; + contentEncryptionKey?: string; + summaryData?: string; + applicationData?: string; + metrics?: AccessReportMetricsApi; +} + +/** + * Service handling server communication/API calls for Access Intelligence endpoints. + * + * Handles making HTTP requests to the Bitwarden server and transforms all responses into Api models. Source of truth for retrieving and updating Access Intelligence report data using the Bitwarden API. + */ +export abstract class AccessIntelligenceApiService { + /** + * Retrieves the latest Access Intelligence report for an Organization. + * @param orgId - the ID of the Organization to retrieve the report for + * @returns the latest Access Intelligence report + */ + abstract getLatestReport$(orgId: OrganizationId): Observable; + + /** + * Creates an Access Intelligence report on the server. + * @param orgId - the ID of the Organization to create the report for + * @param request - contains data used to create the report + * @returns observable emitting the server's response, which includes the created Access Intelligence report + */ + abstract createReport$( + orgId: OrganizationId, + request: AccessReportCreateRequest, + ): Observable; + + /** + * Self-hosted only. Uploads a file containing the Access Intelligence report data directly to a Bitwarden self-hosted server. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report to upload the file for + * @param file - the file containing the Access Intelligence report data + * @param reportFileId - the ID of the report file returned from the server upon report creation + * @returns observable that completes when the upload is successful + */ + abstract uploadReportFile$( + orgId: OrganizationId, + reportId: OrganizationReportId, + file: File, + reportFileId: string, + ): Observable; + + /** + * Retrieves Access Intelligence summary data for an Organization within a date range. + * @param orgId - the ID of the Organization to retrieve summary data for + * @param startDate - the start of the date range (inclusive) + * @param endDate - the end of the date range (inclusive) + * @returns observable emitting an array of summary data records within the given date range + */ + abstract getSummaryDataByDateRange$( + orgId: OrganizationId, + startDate: Date, + endDate: Date, + ): Observable; + + /** + * Updates the summary data for an existing Access Intelligence report. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report to update + * @param summaryData - the encrypted summary data to store on the report + * @param metrics - optional map of metric names to their values + * @returns observable emitting the updated Access Intelligence report + */ + abstract updateSummaryData$( + orgId: OrganizationId, + reportId: OrganizationReportId, + summaryData: string, + metrics?: Record, + ): Observable; + + /** + * Updates the application data for an existing Access Intelligence report. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report to update + * @param applicationData - the encrypted application data to store on the report + * @returns observable emitting the updated Access Intelligence report + */ + abstract updateApplicationData$( + orgId: OrganizationId, + reportId: OrganizationReportId, + applicationData: string, + ): Observable; + + /** + * Renews the upload link for an Access Intelligence report file. Used when a prior upload attempt failed or expired. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report whose upload link should be renewed + * @returns observable emitting the renewed report file metadata, including a fresh upload URL + */ + abstract renewReportFileUploadLink$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable; + + /** + * Deletes an Access Intelligence report from the server. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report to delete + * @returns observable that completes when the report has been deleted + */ + abstract deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable; + + /** + * Self-hosted only. Downloads the file for an Access Intelligence report. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report whose file to download + * @returns observable emitting the file blob and its filename + */ + abstract downloadReportFile$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable<{ blob: Blob; fileName: string }>; + + /** + * Updates an existing Access Intelligence report. + * @param orgId - the ID of the Organization the report belongs to + * @param reportId - the ID of the report to update + * @param request - the data to update on the report + * @returns observable emitting the updated Access Intelligence report + */ + abstract updateReport$( + orgId: OrganizationId, + reportId: OrganizationReportId, + request: AccessReportUpdateRequest, + ): Observable; +} diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts new file mode 100644 index 000000000000..0d07f9966be8 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -0,0 +1,401 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { FileUploadType } from "@bitwarden/common/platform/enums"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; + +import { AccessReportApi, AccessReportFileApi, AccessReportSummaryApi } from "../../../models"; +import { + AccessReportCreateRequest, + AccessReportUpdateRequest, +} from "../../abstractions/access-intelligence-api.service"; + +import { DefaultAccessIntelligenceApiService } from "./default-access-intelligence-api.service"; + +describe("DefaultAccessIntelligenceApiService", () => { + let service: DefaultAccessIntelligenceApiService; + let mockApiService: MockProxy; + + const orgId = "org-123" as OrganizationId; + const reportId = "report-456" as OrganizationReportId; + const reportFileId = "file-789"; + + beforeEach(() => { + mockApiService = mock(); + service = new DefaultAccessIntelligenceApiService(mockApiService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("getLatestReport$", () => { + it("should call GET /reports/organizations/{orgId}/latest and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + reportData: "encrypted-reports", + summaryData: "encrypted-summary", + applicationData: "encrypted-apps", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const result = await firstValueFrom(service.getLatestReport$(orgId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId}/latest`, + null, + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportApi); + expect(result.id).toBe(reportId); + expect(result.organizationId).toBe(orgId); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Network error")); + + await expect(firstValueFrom(service.getLatestReport$(orgId))).rejects.toThrow( + "Network error", + ); + }); + }); + + describe("createReport$", () => { + it("should call POST /reports/organizations/{orgId} and return AccessReportFileApi", async () => { + const rawResponse = { + reportFileUploadUrl: "https://storage.example.com/upload", + fileUploadType: FileUploadType.Azure, + reportResponse: { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + }, + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const request: AccessReportCreateRequest = { + fileSize: 1024, + contentEncryptionKey: "enc-key", + }; + + const result = await firstValueFrom(service.createReport$(orgId, request)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + `/reports/organizations/${orgId}`, + request, + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportFileApi); + expect(result.reportFileUploadUrl).toBe("https://storage.example.com/upload"); + expect(result.fileUploadType).toBe(FileUploadType.Azure); + expect(result.reportResponse).toBeInstanceOf(AccessReportApi); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("API error")); + + await expect(firstValueFrom(service.createReport$(orgId, {}))).rejects.toThrow("API error"); + }); + }); + + describe("updateSummaryData$", () => { + it("should call PATCH /reports/organizations/{orgId}/data/summary/{reportId} and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + summaryData: "encrypted-summary", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const summaryData = "encrypted-summary-data"; + const metrics = { totalApplicationCount: 5, totalAtRiskApplicationCount: 2 }; + + const result = await firstValueFrom( + service.updateSummaryData$(orgId, reportId, summaryData, metrics), + ); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + `/reports/organizations/${orgId}/data/summary/${reportId}`, + expect.objectContaining({ summaryData, metrics }), + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportApi); + expect(result.id).toBe(reportId); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Update failed")); + + await expect( + firstValueFrom(service.updateSummaryData$(orgId, reportId, "data")), + ).rejects.toThrow("Update failed"); + }); + }); + + describe("updateApplicationData$", () => { + it("should call PATCH /reports/organizations/{orgId}/data/application/{reportId} and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + applicationData: "encrypted-apps", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const applicationData = "encrypted-app-data"; + + const result = await firstValueFrom( + service.updateApplicationData$(orgId, reportId, applicationData), + ); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + `/reports/organizations/${orgId}/data/application/${reportId}`, + expect.objectContaining({ applicationData }), + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportApi); + expect(result.id).toBe(reportId); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Update failed")); + + await expect( + firstValueFrom(service.updateApplicationData$(orgId, reportId, "data")), + ).rejects.toThrow("Update failed"); + }); + }); + + describe("getSummaryDataByDateRange$", () => { + const startDate = new Date("2024-01-01"); + const endDate = new Date("2024-01-31"); + + it("should call GET with date range params and return AccessReportSummaryApi[]", async () => { + const rawResponse = [ + { EncryptedData: "enc-data-1", EncryptionKey: "key-1", Date: "2024-01-15" }, + { EncryptedData: "enc-data-2", EncryptionKey: "key-2", Date: "2024-01-20" }, + ]; + mockApiService.send.mockResolvedValue(rawResponse); + + const result = await firstValueFrom( + service.getSummaryDataByDateRange$(orgId, startDate, endDate), + ); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId}/data/summary?startDate=2024-01-01&endDate=2024-01-31`, + null, + true, + true, + ); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(AccessReportSummaryApi); + expect(result[0].encryptedData).toBe("enc-data-1"); + }); + + it("should return empty array when response is not an array", async () => { + mockApiService.send.mockResolvedValue(null); + + const result = await firstValueFrom( + service.getSummaryDataByDateRange$(orgId, startDate, endDate), + ); + + expect(result).toEqual([]); + }); + + it("should return empty array on 404 error", async () => { + const notFoundError = new ErrorResponse({ Message: "Not found" }, 404); + mockApiService.send.mockRejectedValue(notFoundError); + + const result = await firstValueFrom( + service.getSummaryDataByDateRange$(orgId, startDate, endDate), + ); + + expect(result).toEqual([]); + }); + + it("should propagate non-404 errors", async () => { + const serverError = new ErrorResponse({ Message: "Server error" }, 500); + mockApiService.send.mockRejectedValue(serverError); + + await expect( + firstValueFrom(service.getSummaryDataByDateRange$(orgId, startDate, endDate)), + ).rejects.toBeInstanceOf(ErrorResponse); + }); + }); + + describe("renewReportFileUpload$", () => { + it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportFileApi", async () => { + const rawResponse = { + reportFileUploadUrl: "https://storage.example.com/renewed-upload", + fileUploadType: FileUploadType.Azure, + reportResponse: { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + contentEncryptionKey: "enc-key", + }, + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const result = await firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId}/${reportId}/file/renew`, + null, + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportFileApi); + expect(result.reportResponse.id).toBe(reportId); + expect(result.reportFileUploadUrl).toBe("https://storage.example.com/renewed-upload"); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Renew failed")); + + await expect( + firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)), + ).rejects.toThrow("Renew failed"); + }); + }); + + describe("deleteReport$", () => { + it("should call DELETE /reports/organizations/{orgId}/{reportId}", async () => { + mockApiService.send.mockResolvedValue(undefined); + + await firstValueFrom(service.deleteReport$(orgId, reportId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "DELETE", + `/reports/organizations/${orgId}/${reportId}`, + null, + true, + false, + ); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Delete failed")); + + await expect(firstValueFrom(service.deleteReport$(orgId, reportId))).rejects.toThrow( + "Delete failed", + ); + }); + }); + + describe("uploadReportFile$", () => { + it("should call POST /reports/organizations/{orgId}/{reportId}/file/report-data with FormData", async () => { + mockApiService.send.mockResolvedValue(undefined); + + const file = new File(["file content"], "report.bin", { type: "application/octet-stream" }); + + await firstValueFrom(service.uploadReportFile$(orgId, reportId, file, reportFileId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + `/reports/organizations/${orgId}/${reportId}/file?reportFileId=${reportFileId}`, + expect.any(FormData), + true, + false, + ); + + const sentFormData: FormData = mockApiService.send.mock.calls[0][2] as FormData; + expect(sentFormData.get("file")).toBeInstanceOf(File); + expect((sentFormData.get("file") as File).name).toBe("report.bin"); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Upload failed")); + + const file = new File(["content"], "report.bin"); + + await expect( + firstValueFrom(service.uploadReportFile$(orgId, reportId, file, reportFileId)), + ).rejects.toThrow("Upload failed"); + }); + }); + + describe("downloadReportFile$", () => { + it("should call GET /reports/organizations/{orgId}/{reportId}/file/download and return blob with fileName", async () => { + const blob = new Blob(["file content"], { type: "application/octet-stream" }); + const sendResponse = { blob, fileName: "report.bin" }; + mockApiService.send.mockResolvedValue(sendResponse); + + const result = await firstValueFrom(service.downloadReportFile$(orgId, reportId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId}/${reportId}/file/download`, + null, + true, + true, + ); + expect(result.blob).toBe(blob); + expect(result.fileName).toBe("report.bin"); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Download failed")); + + await expect(firstValueFrom(service.downloadReportFile$(orgId, reportId))).rejects.toThrow( + "Download failed", + ); + }); + }); + + describe("updateReport$", () => { + it("should call PATCH /reports/organizations/{orgId}/{reportId} and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + summaryData: "encrypted-summary", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const request: AccessReportUpdateRequest = { + summaryData: "encrypted-summary", + contentEncryptionKey: "enc-key", + }; + + const result = await firstValueFrom(service.updateReport$(orgId, reportId, request)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + `/reports/organizations/${orgId}/${reportId}`, + request, + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportApi); + expect(result.id).toBe(reportId); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Update failed")); + + await expect(firstValueFrom(service.updateReport$(orgId, reportId, {}))).rejects.toThrow( + "Update failed", + ); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts new file mode 100644 index 000000000000..3c5079fc3823 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -0,0 +1,180 @@ +import { catchError, from, map, Observable, of, throwError } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; + +import { AccessReportApi, AccessReportFileApi, AccessReportSummaryApi } from "../../../models"; +import { + AccessIntelligenceApiService, + AccessReportCreateRequest, + AccessReportUpdateRequest, +} from "../../abstractions/access-intelligence-api.service"; + +export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiService { + constructor(private apiService: ApiService) { + super(); + } + + getLatestReport$(orgId: OrganizationId): Observable { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId.toString()}/latest`, + null, + true, + true, + ); + return from(response).pipe(map((res) => new AccessReportApi(res))); + } + + createReport$( + orgId: OrganizationId, + request: AccessReportCreateRequest, + ): Observable { + const response = this.apiService.send( + "POST", + `/reports/organizations/${orgId.toString()}`, + request, + true, + true, + ); + return from(response).pipe(map((response) => new AccessReportFileApi(response))); + } + + updateSummaryData$( + orgId: OrganizationId, + reportId: OrganizationReportId, + summaryData: string, + metrics?: Record, + ): Observable { + const response = this.apiService.send( + "PATCH", + `/reports/organizations/${orgId.toString()}/data/summary/${reportId.toString()}`, + { summaryData, metrics, reportId: reportId, organizationId: orgId }, + true, + true, + ); + + return from(response).pipe(map((response) => new AccessReportApi(response))); + } + + updateApplicationData$( + orgId: OrganizationId, + reportId: OrganizationReportId, + applicationData: string, + ): Observable { + const response = this.apiService.send( + "PATCH", + `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, + { applicationData, id: reportId, organizationId: orgId }, + true, + true, + ); + + return from(response).pipe(map((response) => new AccessReportApi(response))); + } + + getSummaryDataByDateRange$( + orgId: OrganizationId, + startDate: Date, + endDate: Date, + ): Observable { + const startDateStr = startDate.toISOString().split("T")[0]; + const endDateStr = endDate.toISOString().split("T")[0]; + const dbResponse = this.apiService.send( + "GET", + `/reports/organizations/${orgId.toString()}/data/summary?startDate=${startDateStr}&endDate=${endDateStr}`, + null, + true, + true, + ); + + return from(dbResponse).pipe( + map((response: any[]) => + Array.isArray(response) ? response.map((r) => new AccessReportSummaryApi(r)) : [], + ), + catchError((error: unknown) => { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return of([]); + } + return throwError(() => error); + }), + ); + } + + renewReportFileUploadLink$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId}/${reportId}/file/renew`, + null, + true, + true, + ); + return from(response).pipe(map((res) => new AccessReportFileApi(res))); + } + + deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable { + const response = this.apiService.send( + "DELETE", + `/reports/organizations/${orgId}/${reportId}`, + null, + true, + false, + ); + return from(response); + } + + uploadReportFile$( + orgId: OrganizationId, + reportId: OrganizationReportId, + file: File, + reportFileId: string, + ): Observable { + const formData = new FormData(); + formData.append("file", file, file.name); + + const response = this.apiService.send( + "POST", + `/reports/organizations/${orgId}/${reportId}/file?reportFileId=${reportFileId}`, + formData, + true, + false, + ); + + return from(response); + } + + downloadReportFile$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable<{ blob: Blob; fileName: string }> { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId}/${reportId}/file/download`, + null, + true, + true, + ); + + return from(response); + } + + updateReport$( + orgId: OrganizationId, + reportId: OrganizationReportId, + request: AccessReportUpdateRequest, + ): Observable { + const response = this.apiService.send( + "PATCH", + `/reports/organizations/${orgId}/${reportId}`, + request, + true, + true, + ); + + return from(response).pipe(map((response) => new AccessReportApi(response))); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index b1dae16e138b..319fa9d7f078 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1693,11 +1693,19 @@ export class ApiService implements ApiServiceAbstraction { const responseType = response.headers.get("content-type"); const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1; const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1; + const responseIsBlob = + responseType != null && responseType.indexOf("application/octet-stream") !== -1; if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) { const responseJson = await response.json(); return responseJson; } else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) { return await response.text(); + } else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsBlob) { + const disposition = response.headers.get("Content-Disposition") ?? ""; + const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + const fileName = match ? match[1].replace(/['"]/g, "") : "download"; + const blob = await response.blob(); + return { blob, fileName }; } else if ( response.status !== HttpStatusCode.Ok && response.status !== HttpStatusCode.NoContent