From aa3172ac6a0e3eadc851be0d2fde533d15a0192b Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 25 Mar 2026 16:48:43 -0700 Subject: [PATCH 01/19] add feature flag and abstract AccessIntelligenceApiService --- .../api/access-report-file-response.api.ts | 28 ++++++++ .../models/api/access-report.api.ts | 4 ++ .../api/organization-report-data.api.ts | 18 +++++ .../dirt/access-intelligence/models/index.ts | 2 + .../access-intelligence-api.service.ts | 71 +++++++++++++++++++ libs/common/src/enums/feature-flag.enum.ts | 2 + 6 files changed, 125 insertions(+) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts new file mode 100644 index 000000000000..ca54e352f218 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts @@ -0,0 +1,28 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +import { AccessReportApi } from "./access-report.api"; + +/** + * API response model returned by createReport (V2) and updateSummary (V2 when requiresNewFileUpload: true). + * Maps OrganizationReportFileResponseModel from the server. + */ +export class AccessReportFileResponseApi extends BaseResponse { + reportFileUploadUrl: string = ""; + reportResponse: AccessReportApi = new AccessReportApi(); + fileUploadType: number = 0; // FileUploadType: 0 = Direct, 1 = Azure + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.reportFileUploadUrl = this.getResponseProperty("reportFileUploadUrl") ?? ""; + this.fileUploadType = this.getResponseProperty("fileUploadType") ?? 0; + + const reportResponse = this.getResponseProperty("reportResponse"); + if (reportResponse != null) { + this.reportResponse = new AccessReportApi(reportResponse); + } + } +} 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..06615a962987 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 @@ -24,6 +24,8 @@ export class AccessReportApi extends BaseResponse { memberRegistry: string = ""; creationDate: string = ""; contentEncryptionKey: string = ""; + reportFile?: string; + reportFileDownloadUrl?: string; constructor(data: any = null) { super(data); @@ -39,6 +41,8 @@ export class AccessReportApi extends BaseResponse { this.summary = this.getResponseProperty("summaryData"); this.memberRegistry = this.getResponseProperty("memberRegistry") ?? ""; this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); + this.reportFile = this.getResponseProperty("reportFile") ?? undefined; + this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? 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/organization-report-data.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts new file mode 100644 index 000000000000..12a4667931e1 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +/** + * API response model returned by GET /data/report/{reportId}. + * Maps OrganizationReportDataResponseModel from the server. + */ +export class OrganizationReportDataApi extends BaseResponse { + reportData: string | null = null; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.reportData = this.getResponseProperty("reportData") ?? null; + } +} 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..e39a1338e195 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 @@ -1,9 +1,11 @@ // API layer export * from "./api/access-report.api"; +export * from "./api/access-report-file-response.api"; 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/organization-report-data.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..c84f64b6cd9e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/abstractions/access-intelligence-api.service.ts @@ -0,0 +1,71 @@ +import { Observable } from "rxjs"; + +import { OrganizationId } from "@bitwarden/sdk-internal"; + +import { + AccessReportApi, + AccessReportFileResponseApi, + AccessReportSummaryApi, + OrganizationReportDataApi, +} from "../../models"; + +export abstract class AccessIntelligenceApiService { + /** GET /reports/organizations/{orgId}/latest */ + abstract getLatestReport$(orgId: OrganizationId): Observable; + + /** + * POST /reports/organizations/{orgId} + * V2: returns upload URL + report metadata. V1: returns report metadata only. + */ + abstract createReport$( + orgId: OrganizationId, + request: AccessReportApi, + fileSize?: number, + ): Observable; + + /** + * POST /reports/organizations/{orgId}/{reportId}/file/report-data + * Self-hosted only. Uploads report data file via multipart form data. + */ + abstract uploadReportFile$( + orgId: OrganizationId, + reportId: string, + file: File, + reportFileId: string, + ): Observable; + + /** GET /reports/organizations/{orgId}/data/summary?startDate=&endDate= */ + abstract getSummaryDataByDateRange$( + orgId: OrganizationId, + startDate: Date, + endDate: Date, + ): Observable; + + /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ + abstract updateSummary$( + orgId: OrganizationId, + reportId: string, + summaryData: string | null, + metrics?: Record, + ): Observable; + + /** GET /reports/organizations/{orgId}/data/report/{reportId} */ + abstract getReportData$( + orgId: OrganizationId, + reportId: string, + ): Observable; + + /** PATCH /reports/organizations/{orgId}/data/report/{reportId} */ + abstract updateReportData$( + orgId: OrganizationId, + reportId: string, + reportData: string | null, + ): Observable; + + /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ + abstract updateApplicationData$( + orgId: OrganizationId, + reportId: string, + applicationData: string | null, + ): Observable; +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2c1d90c1d6f2..9437ea96e839 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -64,6 +64,7 @@ export enum FeatureFlag { Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", AccessIntelligenceTrendChart = "pm-26961-access-intelligence-trend-chart", AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture", + AccessIntelligenceAzureFileStorage = "pm-31920-access-intelligence-azure-file-storage", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -126,6 +127,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.Milestone11AppPageImprovements]: FALSE, [FeatureFlag.AccessIntelligenceTrendChart]: FALSE, [FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE, + [FeatureFlag.AccessIntelligenceAzureFileStorage]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, From f18404b4e164bd464a7221e046658effd7b70918 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 26 Mar 2026 12:18:22 -0700 Subject: [PATCH 02/19] add default implementation of AccessIntelligenceApiService --- .../models/api/access-report.api.ts | 2 + .../access-intelligence-api.service.ts | 23 +-- ...default-access-intelligence-api.service.ts | 135 ++++++++++++++++++ 3 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts 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 06615a962987..d63a4213101a 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 @@ -26,6 +26,7 @@ export class AccessReportApi extends BaseResponse { contentEncryptionKey: string = ""; reportFile?: string; reportFileDownloadUrl?: string; + fileSize?: number; constructor(data: any = null) { super(data); @@ -43,6 +44,7 @@ export class AccessReportApi extends BaseResponse { this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); this.reportFile = this.getResponseProperty("reportFile") ?? undefined; this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; + this.fileSize = this.getResponseProperty("fileSize") ?? undefined; // Use when individual values are encrypted // const summary = this.getResponseProperty("summaryData"); 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 index c84f64b6cd9e..f39bdaf230ce 100644 --- 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 @@ -2,12 +2,7 @@ import { Observable } from "rxjs"; import { OrganizationId } from "@bitwarden/sdk-internal"; -import { - AccessReportApi, - AccessReportFileResponseApi, - AccessReportSummaryApi, - OrganizationReportDataApi, -} from "../../models"; +import { AccessReportApi, AccessReportFileResponseApi, AccessReportSummaryApi } from "../../models"; export abstract class AccessIntelligenceApiService { /** GET /reports/organizations/{orgId}/latest */ @@ -20,7 +15,6 @@ export abstract class AccessIntelligenceApiService { abstract createReport$( orgId: OrganizationId, request: AccessReportApi, - fileSize?: number, ): Observable; /** @@ -42,26 +36,13 @@ export abstract class AccessIntelligenceApiService { ): Observable; /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ - abstract updateSummary$( + abstract updateSummaryData$( orgId: OrganizationId, reportId: string, summaryData: string | null, metrics?: Record, ): Observable; - /** GET /reports/organizations/{orgId}/data/report/{reportId} */ - abstract getReportData$( - orgId: OrganizationId, - reportId: string, - ): Observable; - - /** PATCH /reports/organizations/{orgId}/data/report/{reportId} */ - abstract updateReportData$( - orgId: OrganizationId, - reportId: string, - reportData: string | null, - ): Observable; - /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ abstract updateApplicationData$( orgId: OrganizationId, 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..3579f9a00ed5 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts @@ -0,0 +1,135 @@ +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 } from "@bitwarden/sdk-internal"; + +import { + AccessReportApi, + AccessReportFileResponseApi, + AccessReportSummaryApi, +} from "../../../models"; +import { AccessIntelligenceApiService } 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: AccessReportApi, + ): Observable { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId.toString()}/latest`, + request, + true, + true, + ); + return from(response).pipe( + map((res) => { + const fileResponse = new AccessReportFileResponseApi(res); + + // response for file upload requests + if (fileResponse.reportFileUploadUrl != "") { + return fileResponse; + } + + return new AccessReportApi(res); + }), + ); + } + + updateSummaryData$( + orgId: OrganizationId, + reportId: string, + summaryData: string | null, + 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: string, + applicationData: string | null, + ): 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); + }), + ); + } + + uploadReportFile$( + orgId: OrganizationId, + reportId: string, + 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/report-data?reportFileId=${reportFileId}`, + formData, + true, + false, + ); + + return from(response); + } +} From c7ec428618366c5b2732df2880c3cdadd1b0fe40 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 31 Mar 2026 14:35:24 -0700 Subject: [PATCH 03/19] remove feature flag for now --- libs/common/src/enums/feature-flag.enum.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9437ea96e839..2c1d90c1d6f2 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -64,7 +64,6 @@ export enum FeatureFlag { Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", AccessIntelligenceTrendChart = "pm-26961-access-intelligence-trend-chart", AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture", - AccessIntelligenceAzureFileStorage = "pm-31920-access-intelligence-azure-file-storage", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -127,7 +126,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.Milestone11AppPageImprovements]: FALSE, [FeatureFlag.AccessIntelligenceTrendChart]: FALSE, [FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE, - [FeatureFlag.AccessIntelligenceAzureFileStorage]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, From ef851f1ab91e4b6b5adb33e974c56ded365e4fea Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 31 Mar 2026 16:46:18 -0700 Subject: [PATCH 04/19] update api service to use models --- .../models/api/access-report-create.api.ts | 13 +++++++++ .../api/access-report-file-response.api.ts | 28 ------------------ .../models/api/access-report-file.api.ts | 29 +++++++++++++++++++ .../models/api/access-report.api.ts | 10 ++++--- .../api/organization-report-data.api.ts | 18 ------------ .../models/api/report-file.api.ts | 20 +++++++++++++ .../dirt/access-intelligence/models/index.ts | 4 +-- .../access-intelligence-api.service.ts | 11 +++++-- ...default-access-intelligence-api.service.ts | 20 ++++--------- 9 files changed, 83 insertions(+), 70 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file.api.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/report-file.api.ts 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-response.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts deleted file mode 100644 index ca54e352f218..000000000000 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-file-response.api.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseResponse } from "@bitwarden/common/models/response/base.response"; - -import { AccessReportApi } from "./access-report.api"; - -/** - * API response model returned by createReport (V2) and updateSummary (V2 when requiresNewFileUpload: true). - * Maps OrganizationReportFileResponseModel from the server. - */ -export class AccessReportFileResponseApi extends BaseResponse { - reportFileUploadUrl: string = ""; - reportResponse: AccessReportApi = new AccessReportApi(); - fileUploadType: number = 0; // FileUploadType: 0 = Direct, 1 = Azure - - constructor(data: any = null) { - super(data); - if (data == null) { - return; - } - - this.reportFileUploadUrl = this.getResponseProperty("reportFileUploadUrl") ?? ""; - this.fileUploadType = this.getResponseProperty("fileUploadType") ?? 0; - - const reportResponse = this.getResponseProperty("reportResponse"); - if (reportResponse != null) { - this.reportResponse = new AccessReportApi(reportResponse); - } - } -} 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 d63a4213101a..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,9 +26,8 @@ export class AccessReportApi extends BaseResponse { memberRegistry: string = ""; creationDate: string = ""; contentEncryptionKey: string = ""; - reportFile?: string; + reportFile?: ReportFileApi; reportFileDownloadUrl?: string; - fileSize?: number; constructor(data: any = null) { super(data); @@ -42,9 +43,10 @@ export class AccessReportApi extends BaseResponse { this.summary = this.getResponseProperty("summaryData"); this.memberRegistry = this.getResponseProperty("memberRegistry") ?? ""; this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); - this.reportFile = this.getResponseProperty("reportFile") ?? undefined; this.reportFileDownloadUrl = this.getResponseProperty("reportFileDownloadUrl") ?? undefined; - this.fileSize = this.getResponseProperty("fileSize") ?? 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/organization-report-data.api.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts deleted file mode 100644 index 12a4667931e1..000000000000 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/organization-report-data.api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BaseResponse } from "@bitwarden/common/models/response/base.response"; - -/** - * API response model returned by GET /data/report/{reportId}. - * Maps OrganizationReportDataResponseModel from the server. - */ -export class OrganizationReportDataApi extends BaseResponse { - reportData: string | null = null; - - constructor(data: any = null) { - super(data); - if (data == null) { - return; - } - - this.reportData = this.getResponseProperty("reportData") ?? null; - } -} 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 e39a1338e195..c5a4c864570e 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 @@ -1,11 +1,11 @@ // API layer export * from "./api/access-report.api"; -export * from "./api/access-report-file-response.api"; 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/organization-report-data.api"; +export * from "./api/access-report-create.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 index f39bdaf230ce..c7325417c451 100644 --- 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 @@ -2,7 +2,12 @@ import { Observable } from "rxjs"; import { OrganizationId } from "@bitwarden/sdk-internal"; -import { AccessReportApi, AccessReportFileResponseApi, AccessReportSummaryApi } from "../../models"; +import { + AccessReportApi, + AccessReportCreateApi, + AccessReportFileApi, + AccessReportSummaryApi, +} from "../../models"; export abstract class AccessIntelligenceApiService { /** GET /reports/organizations/{orgId}/latest */ @@ -14,8 +19,8 @@ export abstract class AccessIntelligenceApiService { */ abstract createReport$( orgId: OrganizationId, - request: AccessReportApi, - ): Observable; + request: AccessReportCreateApi, + ): Observable; /** * POST /reports/organizations/{orgId}/{reportId}/file/report-data 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 index 3579f9a00ed5..1a1ce63848a9 100644 --- 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 @@ -6,8 +6,9 @@ import { OrganizationId } from "@bitwarden/sdk-internal"; import { AccessReportApi, - AccessReportFileResponseApi, + AccessReportFileApi, AccessReportSummaryApi, + AccessReportCreateApi, } from "../../../models"; import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; @@ -29,8 +30,8 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe createReport$( orgId: OrganizationId, - request: AccessReportApi, - ): Observable { + request: AccessReportCreateApi, + ): Observable { const response = this.apiService.send( "GET", `/reports/organizations/${orgId.toString()}/latest`, @@ -38,18 +39,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe true, true, ); - return from(response).pipe( - map((res) => { - const fileResponse = new AccessReportFileResponseApi(res); - - // response for file upload requests - if (fileResponse.reportFileUploadUrl != "") { - return fileResponse; - } - - return new AccessReportApi(res); - }), - ); + return from(response).pipe(map((response) => new AccessReportFileApi(response))); } updateSummaryData$( From cad3633765cf532f616f7a215c351479e845e141 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 31 Mar 2026 16:58:56 -0700 Subject: [PATCH 05/19] add tests, fix create endpoint --- .../access-intelligence-api.service.ts | 2 +- ...lt-access-intelligence-api.service.spec.ts | 308 ++++++++++++++++++ ...default-access-intelligence-api.service.ts | 6 +- 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts 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 index c7325417c451..907b1b496409 100644 --- 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 @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { OrganizationId } from "@bitwarden/sdk-internal"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AccessReportApi, 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..db4463ab5ee3 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.spec.ts @@ -0,0 +1,308 @@ +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 } from "@bitwarden/common/types/guid"; + +import { + AccessReportApi, + AccessReportCreateApi, + AccessReportFileApi, + AccessReportSummaryApi, +} from "../../../models"; + +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"; + 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 = new AccessReportCreateApi(); + request.fileSize = 1024; + request.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")); + + const request = new AccessReportCreateApi(); + + await expect(firstValueFrom(service.createReport$(orgId, request))).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 send null summaryData when not provided", async () => { + const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; + mockApiService.send.mockResolvedValue(rawResponse); + + await firstValueFrom(service.updateSummaryData$(orgId, reportId, null)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + expect.any(String), + expect.objectContaining({ summaryData: null }), + true, + true, + ); + }); + + 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 send null applicationData when not provided", async () => { + const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; + mockApiService.send.mockResolvedValue(rawResponse); + + await firstValueFrom(service.updateApplicationData$(orgId, reportId, null)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + expect.any(String), + expect.objectContaining({ applicationData: null }), + true, + true, + ); + }); + + 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("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/report-data?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"); + }); + }); +}); 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 index 1a1ce63848a9..f89ac0d0b60c 100644 --- 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 @@ -2,7 +2,7 @@ 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 } from "@bitwarden/sdk-internal"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -33,8 +33,8 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe request: AccessReportCreateApi, ): Observable { const response = this.apiService.send( - "GET", - `/reports/organizations/${orgId.toString()}/latest`, + "POST", + `/reports/organizations/${orgId.toString()}`, request, true, true, From a068d8b996572f2d6c69c3d9b53289eb44255934 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 11:08:22 -0700 Subject: [PATCH 06/19] remove null values from summary and appplication update endpoints --- .../access-intelligence-api.service.ts | 4 +-- ...lt-access-intelligence-api.service.spec.ts | 30 ------------------- ...default-access-intelligence-api.service.ts | 4 +-- 3 files changed, 4 insertions(+), 34 deletions(-) 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 index 907b1b496409..35290ed2e779 100644 --- 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 @@ -44,7 +44,7 @@ export abstract class AccessIntelligenceApiService { abstract updateSummaryData$( orgId: OrganizationId, reportId: string, - summaryData: string | null, + summaryData: string, metrics?: Record, ): Observable; @@ -52,6 +52,6 @@ export abstract class AccessIntelligenceApiService { abstract updateApplicationData$( orgId: OrganizationId, reportId: string, - applicationData: string | null, + applicationData: string, ): 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 index db4463ab5ee3..356e11c62a45 100644 --- 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 @@ -140,21 +140,6 @@ describe("DefaultAccessIntelligenceApiService", () => { expect(result.id).toBe(reportId); }); - it("should send null summaryData when not provided", async () => { - const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; - mockApiService.send.mockResolvedValue(rawResponse); - - await firstValueFrom(service.updateSummaryData$(orgId, reportId, null)); - - expect(mockApiService.send).toHaveBeenCalledWith( - "PATCH", - expect.any(String), - expect.objectContaining({ summaryData: null }), - true, - true, - ); - }); - it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("Update failed")); @@ -192,21 +177,6 @@ describe("DefaultAccessIntelligenceApiService", () => { expect(result.id).toBe(reportId); }); - it("should send null applicationData when not provided", async () => { - const rawResponse = { id: reportId, organizationId: orgId, creationDate: "" }; - mockApiService.send.mockResolvedValue(rawResponse); - - await firstValueFrom(service.updateApplicationData$(orgId, reportId, null)); - - expect(mockApiService.send).toHaveBeenCalledWith( - "PATCH", - expect.any(String), - expect.objectContaining({ applicationData: null }), - true, - true, - ); - }); - it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("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 index f89ac0d0b60c..74fcca845d4f 100644 --- 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 @@ -45,7 +45,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateSummaryData$( orgId: OrganizationId, reportId: string, - summaryData: string | null, + summaryData: string, metrics?: Record, ): Observable { const response = this.apiService.send( @@ -62,7 +62,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateApplicationData$( orgId: OrganizationId, reportId: string, - applicationData: string | null, + applicationData: string, ): Observable { const response = this.apiService.send( "PATCH", From fb7c07b34ff46c3ace472eccb4df30d029331561 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Wed, 1 Apr 2026 11:39:15 -0700 Subject: [PATCH 07/19] remove bad comment --- .../services/abstractions/access-intelligence-api.service.ts | 1 - 1 file changed, 1 deletion(-) 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 index 35290ed2e779..e7e2666c2fb6 100644 --- 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 @@ -15,7 +15,6 @@ export abstract class AccessIntelligenceApiService { /** * POST /reports/organizations/{orgId} - * V2: returns upload URL + report metadata. V1: returns report metadata only. */ abstract createReport$( orgId: OrganizationId, From e2157effe6c50fcc337052cd047137ba05e166d4 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 15:37:33 -0700 Subject: [PATCH 08/19] add methods for new endpoints --- .../access-intelligence-api.service.ts | 11 +++++++- ...default-access-intelligence-api.service.ts | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) 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 index e7e2666c2fb6..63725b494974 100644 --- 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 @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -53,4 +53,13 @@ export abstract class AccessIntelligenceApiService { reportId: string, applicationData: string, ): Observable; + + /** GET /reports/organizations/{orgId}/{reportId}/renew-upload */ + abstract renewReportFileUpload$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable; + + /** DELETE /reports/organizations/{orgId}/{reportId} */ + abstract deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable; } 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 index 74fcca845d4f..c5374d09e412 100644 --- 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 @@ -2,7 +2,7 @@ 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 } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -103,6 +103,31 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe ); } + renewReportFileUpload$( + orgId: OrganizationId, + reportId: OrganizationReportId, + ): Observable { + const response = this.apiService.send( + "GET", + `/reports/organizations/${orgId}/${reportId}/renew-upload`, + null, + true, + true, + ); + return from(response).pipe(map((res) => new AccessReportApi(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: string, From 6446c74f15c077f723c250e6e044230ec7f117ab Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 15:42:20 -0700 Subject: [PATCH 09/19] add tests, update reportId to use OrganizationReportId type --- .../access-intelligence-api.service.ts | 6 +- ...lt-access-intelligence-api.service.spec.ts | 61 ++++++++++++++++++- ...default-access-intelligence-api.service.ts | 6 +- 3 files changed, 65 insertions(+), 8 deletions(-) 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 index 63725b494974..bc1588fc58c5 100644 --- 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 @@ -27,7 +27,7 @@ export abstract class AccessIntelligenceApiService { */ abstract uploadReportFile$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, file: File, reportFileId: string, ): Observable; @@ -42,7 +42,7 @@ export abstract class AccessIntelligenceApiService { /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ abstract updateSummaryData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, summaryData: string, metrics?: Record, ): Observable; @@ -50,7 +50,7 @@ export abstract class AccessIntelligenceApiService { /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ abstract updateApplicationData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, applicationData: string, ): 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 index 356e11c62a45..db2bec707123 100644 --- 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 @@ -4,7 +4,7 @@ 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 } from "@bitwarden/common/types/guid"; +import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; import { AccessReportApi, @@ -20,7 +20,7 @@ describe("DefaultAccessIntelligenceApiService", () => { let mockApiService: MockProxy; const orgId = "org-123" as OrganizationId; - const reportId = "report-456"; + const reportId = "report-456" as OrganizationReportId; const reportFileId = "file-789"; beforeEach(() => { @@ -244,6 +244,63 @@ describe("DefaultAccessIntelligenceApiService", () => { }); }); + describe("renewReportFileUpload$", () => { + it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + reportFileUploadUrl: "https://storage.example.com/renewed-upload", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const result = await firstValueFrom(service.renewReportFileUpload$(orgId, reportId)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/reports/organizations/${orgId}/${reportId}/renew-upload`, + null, + true, + true, + ); + expect(result).toBeInstanceOf(AccessReportApi); + expect(result.id).toBe(reportId); + }); + + it("should propagate API errors", async () => { + mockApiService.send.mockRejectedValue(new Error("Renew failed")); + + await expect(firstValueFrom(service.renewReportFileUpload$(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); 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 index c5374d09e412..e007e7ecfb99 100644 --- 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 @@ -44,7 +44,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateSummaryData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, summaryData: string, metrics?: Record, ): Observable { @@ -61,7 +61,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe updateApplicationData$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, applicationData: string, ): Observable { const response = this.apiService.send( @@ -130,7 +130,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe uploadReportFile$( orgId: OrganizationId, - reportId: string, + reportId: OrganizationReportId, file: File, reportFileId: string, ): Observable { From 2633f74d4ce1c5001d719518c8f5c518400d93f8 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Thu, 2 Apr 2026 16:01:38 -0700 Subject: [PATCH 10/19] update return type for renew api --- .../access-intelligence-api.service.ts | 2 +- ...ult-access-intelligence-api.service.spec.ts | 18 +++++++++++------- .../default-access-intelligence-api.service.ts | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) 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 index bc1588fc58c5..0776bbacca04 100644 --- 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 @@ -58,7 +58,7 @@ export abstract class AccessIntelligenceApiService { abstract renewReportFileUpload$( orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable; + ): Observable; /** DELETE /reports/organizations/{orgId}/{reportId} */ abstract deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): 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 index db2bec707123..cb554a4d0134 100644 --- 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 @@ -245,13 +245,16 @@ describe("DefaultAccessIntelligenceApiService", () => { }); describe("renewReportFileUpload$", () => { - it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportApi", async () => { + it("should call GET /reports/organizations/{orgId}/{reportId}/renew-upload and return AccessReportFileApi", async () => { const rawResponse = { - id: reportId, - organizationId: orgId, - creationDate: "2024-01-01T00:00:00Z", reportFileUploadUrl: "https://storage.example.com/renewed-upload", - contentEncryptionKey: "enc-key", + fileUploadType: FileUploadType.Azure, + reportResponse: { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + contentEncryptionKey: "enc-key", + }, }; mockApiService.send.mockResolvedValue(rawResponse); @@ -264,8 +267,9 @@ describe("DefaultAccessIntelligenceApiService", () => { true, true, ); - expect(result).toBeInstanceOf(AccessReportApi); - expect(result.id).toBe(reportId); + 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 () => { 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 index e007e7ecfb99..4e89cff31bb7 100644 --- 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 @@ -106,7 +106,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe renewReportFileUpload$( orgId: OrganizationId, reportId: OrganizationReportId, - ): Observable { + ): Observable { const response = this.apiService.send( "GET", `/reports/organizations/${orgId}/${reportId}/renew-upload`, @@ -114,7 +114,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe true, true, ); - return from(response).pipe(map((res) => new AccessReportApi(res))); + return from(response).pipe(map((res) => new AccessReportFileApi(res))); } deleteReport$(orgId: OrganizationId, reportId: OrganizationReportId): Observable { From 5a638c6147a644fe5587a3d5129b8177d5d2d8ef Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 3 Apr 2026 11:00:20 -0700 Subject: [PATCH 11/19] update renew endpoint to correct path --- .../abstractions/access-intelligence-api.service.ts | 4 ++-- .../default-access-intelligence-api.service.spec.ts | 10 +++++----- .../api/default-access-intelligence-api.service.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) 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 index 0776bbacca04..b881e9d1f1d9 100644 --- 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 @@ -54,8 +54,8 @@ export abstract class AccessIntelligenceApiService { applicationData: string, ): Observable; - /** GET /reports/organizations/{orgId}/{reportId}/renew-upload */ - abstract renewReportFileUpload$( + /** GET /reports/organizations/{orgId}/{reportId}/file/renew */ + abstract renewReportFileUploadLink$( orgId: OrganizationId, reportId: OrganizationReportId, ): 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 index cb554a4d0134..7b676f112410 100644 --- 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 @@ -258,11 +258,11 @@ describe("DefaultAccessIntelligenceApiService", () => { }; mockApiService.send.mockResolvedValue(rawResponse); - const result = await firstValueFrom(service.renewReportFileUpload$(orgId, reportId)); + const result = await firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)); expect(mockApiService.send).toHaveBeenCalledWith( "GET", - `/reports/organizations/${orgId}/${reportId}/renew-upload`, + `/reports/organizations/${orgId}/${reportId}/file/renew`, null, true, true, @@ -275,9 +275,9 @@ describe("DefaultAccessIntelligenceApiService", () => { it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("Renew failed")); - await expect(firstValueFrom(service.renewReportFileUpload$(orgId, reportId))).rejects.toThrow( - "Renew failed", - ); + await expect( + firstValueFrom(service.renewReportFileUploadLink$(orgId, reportId)), + ).rejects.toThrow("Renew 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 index 4e89cff31bb7..036e5a0fa5bc 100644 --- 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 @@ -103,13 +103,13 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe ); } - renewReportFileUpload$( + renewReportFileUploadLink$( orgId: OrganizationId, reportId: OrganizationReportId, ): Observable { const response = this.apiService.send( "GET", - `/reports/organizations/${orgId}/${reportId}/renew-upload`, + `/reports/organizations/${orgId}/${reportId}/file/renew`, null, true, true, From 8335384cf641b85f890f4e4a83e4039d96320106 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 10 Apr 2026 11:54:01 -0700 Subject: [PATCH 12/19] add jsdoc to access intelligence api service and methods --- .../dirt/access-intelligence/models/index.ts | 1 - .../access-intelligence-api.service.ts | 75 ++++++++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) 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 c5a4c864570e..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,7 +4,6 @@ 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-create.api"; export * from "./api/access-report-file.api"; // Data layer 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 index b881e9d1f1d9..29fbb5ab24b6 100644 --- 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 @@ -4,26 +4,50 @@ import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/gu import { AccessReportApi, - AccessReportCreateApi, AccessReportFileApi, + AccessReportMetricsApi, AccessReportSummaryApi, } from "../../models"; +export interface AccessReportCreateRequest { + contentEncryptionKey?: string; + summaryData?: string; + applicationData?: string; + metrics?: AccessReportMetricsApi; + fileSize?: number; +} + +/** + * 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 { - /** GET /reports/organizations/{orgId}/latest */ + /** + * 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; /** - * POST /reports/organizations/{orgId} + * 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: AccessReportCreateApi, + request: AccessReportCreateRequest, ): Observable; /** - * POST /reports/organizations/{orgId}/{reportId}/file/report-data - * Self-hosted only. Uploads report data file via multipart form data. + * Used for self-hosted setups 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, @@ -32,14 +56,27 @@ export abstract class AccessIntelligenceApiService { reportFileId: string, ): Observable; - /** GET /reports/organizations/{orgId}/data/summary?startDate=&endDate= */ + /** + * 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; - /** PATCH /reports/organizations/{orgId}/data/summary/{reportId} */ + /** + * 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, @@ -47,19 +84,35 @@ export abstract class AccessIntelligenceApiService { metrics?: Record, ): Observable; - /** PATCH /reports/organizations/{orgId}/data/application/{reportId} */ + /** + * 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; - /** GET /reports/organizations/{orgId}/{reportId}/file/renew */ + /** + * 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; - /** DELETE /reports/organizations/{orgId}/{reportId} */ + /** + * 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; } From 091d62b4d9bbee8dbe43de44d808361cdcf72c96 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 10 Apr 2026 13:49:58 -0700 Subject: [PATCH 13/19] update file upload endpoint --- .../api/default-access-intelligence-api.service.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 index 036e5a0fa5bc..ac17f6157a41 100644 --- 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 @@ -4,13 +4,11 @@ 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 { - AccessReportApi, - AccessReportFileApi, - AccessReportSummaryApi, - AccessReportCreateApi, -} from "../../../models"; -import { AccessIntelligenceApiService } from "../../abstractions/access-intelligence-api.service"; + AccessIntelligenceApiService, + AccessReportCreateRequest, +} from "../../abstractions/access-intelligence-api.service"; export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiService { constructor(private apiService: ApiService) { @@ -30,7 +28,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe createReport$( orgId: OrganizationId, - request: AccessReportCreateApi, + request: AccessReportCreateRequest, ): Observable { const response = this.apiService.send( "POST", @@ -139,7 +137,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe const response = this.apiService.send( "POST", - `/reports/organizations/${orgId}/${reportId}/file/report-data?reportFileId=${reportFileId}`, + `/reports/organizations/${orgId}/${reportId}/file?reportFileId=${reportFileId}`, formData, true, false, From 9c1338d33c3e85fefe38535afe0a21edff401254 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 10 Apr 2026 16:42:05 -0700 Subject: [PATCH 14/19] add missing endpoints. update apiService.send to handle blob responses --- .../access-intelligence-api.service.ts | 35 ++++++++++++++++++- ...default-access-intelligence-api.service.ts | 32 +++++++++++++++++ libs/common/src/services/api.service.ts | 8 +++++ 3 files changed, 74 insertions(+), 1 deletion(-) 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 index 29fbb5ab24b6..24033759048a 100644 --- 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 @@ -10,6 +10,7 @@ import { } from "../../models"; export interface AccessReportCreateRequest { + reportData?: string; contentEncryptionKey?: string; summaryData?: string; applicationData?: string; @@ -17,6 +18,14 @@ export interface AccessReportCreateRequest { 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. * @@ -42,7 +51,7 @@ export abstract class AccessIntelligenceApiService { ): Observable; /** - * Used for self-hosted setups only. Uploads a file containing the Access Intelligence report data directly to a Bitwarden self-hosted server. + * 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 @@ -115,4 +124,28 @@ export abstract class AccessIntelligenceApiService { * @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.ts b/bitwarden_license/bit-common/src/dirt/access-intelligence/services/implementations/api/default-access-intelligence-api.service.ts index ac17f6157a41..3c5079fc3823 100644 --- 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 @@ -8,6 +8,7 @@ import { AccessReportApi, AccessReportFileApi, AccessReportSummaryApi } from ".. import { AccessIntelligenceApiService, AccessReportCreateRequest, + AccessReportUpdateRequest, } from "../../abstractions/access-intelligence-api.service"; export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiService { @@ -145,4 +146,35 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe 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 546dfe2d89dd..baf2010f232b 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1631,11 +1631,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 From b96649ec4fce768fbb389a319e0b99b587e35bdf Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 10 Apr 2026 16:45:22 -0700 Subject: [PATCH 15/19] update/fix tests --- ...lt-access-intelligence-api.service.spec.ts | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) 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 index 7b676f112410..0d07f9966be8 100644 --- 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 @@ -6,12 +6,11 @@ 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 { - AccessReportApi, - AccessReportCreateApi, - AccessReportFileApi, - AccessReportSummaryApi, -} from "../../../models"; + AccessReportCreateRequest, + AccessReportUpdateRequest, +} from "../../abstractions/access-intelligence-api.service"; import { DefaultAccessIntelligenceApiService } from "./default-access-intelligence-api.service"; @@ -81,9 +80,10 @@ describe("DefaultAccessIntelligenceApiService", () => { }; mockApiService.send.mockResolvedValue(rawResponse); - const request = new AccessReportCreateApi(); - request.fileSize = 1024; - request.contentEncryptionKey = "enc-key"; + const request: AccessReportCreateRequest = { + fileSize: 1024, + contentEncryptionKey: "enc-key", + }; const result = await firstValueFrom(service.createReport$(orgId, request)); @@ -103,11 +103,7 @@ describe("DefaultAccessIntelligenceApiService", () => { it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("API error")); - const request = new AccessReportCreateApi(); - - await expect(firstValueFrom(service.createReport$(orgId, request))).rejects.toThrow( - "API error", - ); + await expect(firstValueFrom(service.createReport$(orgId, {}))).rejects.toThrow("API error"); }); }); @@ -315,7 +311,7 @@ describe("DefaultAccessIntelligenceApiService", () => { expect(mockApiService.send).toHaveBeenCalledWith( "POST", - `/reports/organizations/${orgId}/${reportId}/file/report-data?reportFileId=${reportFileId}`, + `/reports/organizations/${orgId}/${reportId}/file?reportFileId=${reportFileId}`, expect.any(FormData), true, false, @@ -336,4 +332,70 @@ describe("DefaultAccessIntelligenceApiService", () => { ).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", + ); + }); + }); }); From 97f5743a1b4eb8316323581a913939bd4945b1f4 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 14 Apr 2026 16:47:20 -0700 Subject: [PATCH 16/19] address PR feedback --- .../models/api/access-report-create.api.ts | 13 --------- .../models/api/access-report-file.api.ts | 4 +-- .../access-intelligence-api.service.ts | 29 +++++++++---------- ...default-access-intelligence-api.service.ts | 15 ++++++---- 4 files changed, 25 insertions(+), 36 deletions(-) delete mode 100644 bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts 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 deleted file mode 100644 index 68d53fd54e05..000000000000 --- a/bitwarden_license/bit-common/src/dirt/access-intelligence/models/api/access-report-create.api.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 index 9c782164682e..24b06e2ea4b3 100644 --- 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 @@ -4,8 +4,8 @@ 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. + * Response model returned when creating an Access Intelligence report to be stored as a file. + * Contains a presigned URL that is used to upload the file for the report. * * - See {@link AccessReportApi} for the nested report response model */ 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 index 24033759048a..d407826cd60f 100644 --- 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 @@ -10,20 +10,17 @@ import { } from "../../models"; export interface AccessReportCreateRequest { - reportData?: string; - contentEncryptionKey?: string; - summaryData?: string; - applicationData?: string; - metrics?: AccessReportMetricsApi; - fileSize?: number; + contentEncryptionKey: string; + summaryData: string; + applicationData: string; + metrics: AccessReportMetricsApi; + fileSize: number; } -export interface AccessReportUpdateRequest { - reportData?: string; - contentEncryptionKey?: string; - summaryData?: string; - applicationData?: string; - metrics?: AccessReportMetricsApi; +export interface AccessReportSettingsUpdateRequest { + summaryData: string; + applicationData: string; + metrics: AccessReportMetricsApi; } /** @@ -90,7 +87,7 @@ export abstract class AccessIntelligenceApiService { orgId: OrganizationId, reportId: OrganizationReportId, summaryData: string, - metrics?: Record, + metrics?: AccessReportMetricsApi, ): Observable; /** @@ -137,15 +134,15 @@ export abstract class AccessIntelligenceApiService { ): Observable<{ blob: Blob; fileName: string }>; /** - * Updates an existing Access Intelligence report. + * Update the settings properties 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 request - the data to update on the report * @returns observable emitting the updated Access Intelligence report */ - abstract updateReport$( + abstract updateReportSettings$( orgId: OrganizationId, reportId: OrganizationReportId, - request: AccessReportUpdateRequest, + request: AccessReportSettingsUpdateRequest, ): Observable; } 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 index 3c5079fc3823..e4fe47b201ac 100644 --- 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 @@ -4,11 +4,16 @@ 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 { + AccessReportApi, + AccessReportFileApi, + AccessReportMetricsApi, + AccessReportSummaryApi, +} from "../../../models"; import { AccessIntelligenceApiService, AccessReportCreateRequest, - AccessReportUpdateRequest, + AccessReportSettingsUpdateRequest, } from "../../abstractions/access-intelligence-api.service"; export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiService { @@ -45,7 +50,7 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe orgId: OrganizationId, reportId: OrganizationReportId, summaryData: string, - metrics?: Record, + metrics?: AccessReportMetricsApi, ): Observable { const response = this.apiService.send( "PATCH", @@ -162,10 +167,10 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe return from(response); } - updateReport$( + updateReportSettings$( orgId: OrganizationId, reportId: OrganizationReportId, - request: AccessReportUpdateRequest, + request: AccessReportSettingsUpdateRequest, ): Observable { const response = this.apiService.send( "PATCH", From 11231fa31d6a90726ae0330011f9f9781627057c Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 14 Apr 2026 16:58:48 -0700 Subject: [PATCH 17/19] update tests --- ...lt-access-intelligence-api.service.spec.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) 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 index 0d07f9966be8..0ef1bdada18d 100644 --- 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 @@ -6,10 +6,15 @@ 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 { + AccessReportApi, + AccessReportFileApi, + AccessReportMetricsApi, + AccessReportSummaryApi, +} from "../../../models"; import { AccessReportCreateRequest, - AccessReportUpdateRequest, + AccessReportSettingsUpdateRequest, } from "../../abstractions/access-intelligence-api.service"; import { DefaultAccessIntelligenceApiService } from "./default-access-intelligence-api.service"; @@ -21,6 +26,20 @@ describe("DefaultAccessIntelligenceApiService", () => { const orgId = "org-123" as OrganizationId; const reportId = "report-456" as OrganizationReportId; const reportFileId = "file-789"; + const mockMetrics = new AccessReportMetricsApi({ + totalApplicationCount: 10, + totalAtRiskApplicationCount: 3, + totalCriticalApplicationCount: 2, + totalCriticalAtRiskApplicationCount: 1, + totalMemberCount: 50, + totalAtRiskMemberCount: 12, + totalCriticalMemberCount: 5, + totalCriticalAtRiskMemberCount: 2, + totalPasswordCount: 200, + totalAtRiskPasswordCount: 40, + totalCriticalPasswordCount: 15, + totalCriticalAtRiskPasswordCount: 5, + }); beforeEach(() => { mockApiService = mock(); @@ -83,6 +102,9 @@ describe("DefaultAccessIntelligenceApiService", () => { const request: AccessReportCreateRequest = { fileSize: 1024, contentEncryptionKey: "enc-key", + summaryData: "encrypted-summary", + applicationData: "encrypted-apps", + metrics: mockMetrics, }; const result = await firstValueFrom(service.createReport$(orgId, request)); @@ -103,7 +125,9 @@ describe("DefaultAccessIntelligenceApiService", () => { it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("API error")); - await expect(firstValueFrom(service.createReport$(orgId, {}))).rejects.toThrow("API error"); + await expect(firstValueFrom(service.createReport$(orgId, {} as any))).rejects.toThrow( + "API error", + ); }); }); @@ -119,7 +143,7 @@ describe("DefaultAccessIntelligenceApiService", () => { mockApiService.send.mockResolvedValue(rawResponse); const summaryData = "encrypted-summary-data"; - const metrics = { totalApplicationCount: 5, totalAtRiskApplicationCount: 2 }; + const metrics = mockMetrics; const result = await firstValueFrom( service.updateSummaryData$(orgId, reportId, summaryData, metrics), @@ -361,23 +385,23 @@ describe("DefaultAccessIntelligenceApiService", () => { }); }); - describe("updateReport$", () => { + describe("updateReportSettings$", () => { 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 = { + const request: AccessReportSettingsUpdateRequest = { summaryData: "encrypted-summary", - contentEncryptionKey: "enc-key", + applicationData: "encrypted-apps", + metrics: mockMetrics, }; - const result = await firstValueFrom(service.updateReport$(orgId, reportId, request)); + const result = await firstValueFrom(service.updateReportSettings$(orgId, reportId, request)); expect(mockApiService.send).toHaveBeenCalledWith( "PATCH", @@ -393,9 +417,9 @@ describe("DefaultAccessIntelligenceApiService", () => { it("should propagate API errors", async () => { mockApiService.send.mockRejectedValue(new Error("Update failed")); - await expect(firstValueFrom(service.updateReport$(orgId, reportId, {}))).rejects.toThrow( - "Update failed", - ); + await expect( + firstValueFrom(service.updateReportSettings$(orgId, reportId, {} as any)), + ).rejects.toThrow("Update failed"); }); }); }); From d67168b8510c335b45151520401f3a91edb3b1b1 Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Tue, 14 Apr 2026 17:08:08 -0700 Subject: [PATCH 18/19] add method for creating legacy reports to api service. includes tests --- .../access-intelligence-api.service.ts | 21 ++++++++- ...lt-access-intelligence-api.service.spec.ts | 45 +++++++++++++++++++ ...default-access-intelligence-api.service.ts | 15 +++++++ 3 files changed, 80 insertions(+), 1 deletion(-) 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 index d407826cd60f..519c0790a5e9 100644 --- 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 @@ -17,6 +17,14 @@ export interface AccessReportCreateRequest { fileSize: number; } +export interface AccessReportLegacyCreateRequest { + reportData: string; + contentEncryptionKey: string; + summaryData: string; + applicationData: string; + metrics: AccessReportMetricsApi; +} + export interface AccessReportSettingsUpdateRequest { summaryData: string; applicationData: string; @@ -37,7 +45,7 @@ export abstract class AccessIntelligenceApiService { abstract getLatestReport$(orgId: OrganizationId): Observable; /** - * Creates an Access Intelligence report on the server. + * Creates an Access Intelligence report on the server, where the report contents are stored as a file. * @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 @@ -47,6 +55,17 @@ export abstract class AccessIntelligenceApiService { request: AccessReportCreateRequest, ): Observable; + /** + * Creates an Access Intelligence report on the server, where report contents are included directly in the request body. + * @param orgId - the ID of the Organization to create the report for + * @param request - contains the report data and metadata used to create the report + * @returns observable emitting the created Access Intelligence report + */ + abstract createLegacyReport$( + orgId: OrganizationId, + request: AccessReportLegacyCreateRequest, + ): 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 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 index 0ef1bdada18d..7de1f6e710b7 100644 --- 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 @@ -14,6 +14,7 @@ import { } from "../../../models"; import { AccessReportCreateRequest, + AccessReportLegacyCreateRequest, AccessReportSettingsUpdateRequest, } from "../../abstractions/access-intelligence-api.service"; @@ -131,6 +132,50 @@ describe("DefaultAccessIntelligenceApiService", () => { }); }); + describe("createLegacyReport$", () => { + it("should call POST /reports/organizations/{orgId} and return AccessReportApi", async () => { + const rawResponse = { + id: reportId, + organizationId: orgId, + creationDate: "2024-01-01T00:00:00Z", + reportData: "encrypted-report-data", + summaryData: "encrypted-summary", + applicationData: "encrypted-apps", + contentEncryptionKey: "enc-key", + }; + mockApiService.send.mockResolvedValue(rawResponse); + + const request: AccessReportLegacyCreateRequest = { + reportData: "encrypted-report-data", + contentEncryptionKey: "enc-key", + summaryData: "encrypted-summary", + applicationData: "encrypted-apps", + metrics: mockMetrics, + }; + + const result = await firstValueFrom(service.createLegacyReport$(orgId, request)); + + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + `/reports/organizations/${orgId}`, + request, + 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("API error")); + + await expect(firstValueFrom(service.createLegacyReport$(orgId, {} as any))).rejects.toThrow( + "API error", + ); + }); + }); + describe("updateSummaryData$", () => { it("should call PATCH /reports/organizations/{orgId}/data/summary/{reportId} and return AccessReportApi", async () => { const rawResponse = { 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 index e4fe47b201ac..d3b4df1a0fb2 100644 --- 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 @@ -13,6 +13,7 @@ import { import { AccessIntelligenceApiService, AccessReportCreateRequest, + AccessReportLegacyCreateRequest, AccessReportSettingsUpdateRequest, } from "../../abstractions/access-intelligence-api.service"; @@ -46,6 +47,20 @@ export class DefaultAccessIntelligenceApiService extends AccessIntelligenceApiSe return from(response).pipe(map((response) => new AccessReportFileApi(response))); } + createLegacyReport$( + orgId: OrganizationId, + request: AccessReportLegacyCreateRequest, + ): Observable { + const response = this.apiService.send( + "POST", + `/reports/organizations/${orgId.toString()}`, + request, + true, + true, + ); + return from(response).pipe(map((res) => new AccessReportApi(res))); + } + updateSummaryData$( orgId: OrganizationId, reportId: OrganizationReportId, From 67a6c1ea29cfa6df99e78d82a1516ff57cd20d0e Mon Sep 17 00:00:00 2001 From: Brad Deibert Date: Fri, 17 Apr 2026 13:40:21 -0700 Subject: [PATCH 19/19] make report file id required --- .../dirt/access-intelligence/models/api/report-file.api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 62e5acb122cc..824fb286e61b 100644 --- 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 @@ -4,7 +4,7 @@ import { BaseResponse } from "@bitwarden/common/models/response/base.response"; * Metadata for an uploaded report file. */ export class ReportFileApi extends BaseResponse { - id: string | undefined; + id: string; fileName: string = ""; /** File size in bytes. Serialized as a string by the server. */ size: number = 0; @@ -12,7 +12,7 @@ export class ReportFileApi extends BaseResponse { constructor(data: any) { super(data); - this.id = this.getResponseProperty("id") ?? undefined; + this.id = this.getResponseProperty("id"); this.fileName = this.getResponseProperty("fileName") ?? ""; this.size = Number(this.getResponseProperty("size") ?? 0); this.validated = this.getResponseProperty("validated") ?? false;