diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts index 29793a41ec9..7c2cc99e300 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts @@ -5,6 +5,8 @@ import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -77,6 +79,8 @@ describe("AttachmentsV2Component", () => { provide: AccountService, useValue: accountService, }, + { provide: ApiService, useValue: mock() }, + { provide: OrganizationService, useValue: mock() }, ], }) .overrideComponent(AttachmentsV2Component, { diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 8cb54d9a911..ccb97e2a703 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -809,6 +809,7 @@ export class VaultComponent implements OnInit, OnDestroy { const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, + organizationId: cipher.organizationId as OrganizationId, }); const result = await firstValueFrom(dialogRef.closed); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 99159e7e2fc..d52f227fab5 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -20,7 +20,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { CipherId, CollectionId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -441,14 +441,15 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return; } - const dialogRef = this.dialogService.open( - AttachmentsV2Component, - { - data: { - cipherId: this.formConfig.originalCipher?.id as CipherId, - }, + const dialogRef = this.dialogService.open< + AttachmentDialogCloseResult, + { cipherId: CipherId; organizationId?: OrganizationId } + >(AttachmentsV2Component, { + data: { + cipherId: this.formConfig.originalCipher?.id as CipherId, + organizationId: this.formConfig.originalCipher?.organizationId as OrganizationId, }, - ); + }); const result = await firstValueFrom(dialogRef.closed); diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts index 2eab6faec36..5dcbf0d4e78 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts @@ -8,7 +8,7 @@ import { switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -155,14 +155,15 @@ export class AddEditComponentV2 implements OnInit { * Opens the attachments dialog. */ async openAttachmentsDialog() { - this.dialogService.open( + this.dialogService.open< AttachmentsV2Component, - { - data: { - cipherId: this.config.originalCipher?.id as CipherId, - }, + { cipherId: CipherId; organizationId?: OrganizationId } + >(AttachmentsV2Component, { + data: { + cipherId: this.config.originalCipher?.id as CipherId, + organizationId: this.config.originalCipher?.organizationId as OrganizationId, }, - ); + }); } /** diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 5f56ecc9e04..67bd6a6a526 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -652,6 +652,7 @@ export class VaultComponent implements OnInit, OnDestroy { const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, + organizationId: cipher.organizationId as OrganizationId, }); const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index f8aeb695e4f..71463e96e8e 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -3,6 +3,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -86,6 +88,14 @@ describe("CipherAttachmentsComponent", () => { provide: AccountService, useValue: accountService, }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: OrganizationService, + useValue: mock(), + }, ], }) .overrideComponent(CipherAttachmentsComponent, { @@ -234,7 +244,21 @@ describe("CipherAttachmentsComponent", () => { it("calls `saveAttachmentWithServer`", async () => { await component.submit(); - expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file, mockUserId); + expect(saveAttachmentWithServer).toHaveBeenCalledWith( + cipherDomain, + file, + mockUserId, + false, + ); + }); + + it("calls `saveAttachmentWithServer` with isAdmin=true when using admin API", async () => { + // Set isAdmin to true to use admin API + Object.defineProperty(component, "isAdmin", { value: true }); + + await component.submit(); + + expect(saveAttachmentWithServer).toHaveBeenCalledWith(cipherDomain, file, mockUserId, true); }); it("resets form and input values", async () => { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index 5380f9e434e..e381f6f07ea 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -24,12 +24,16 @@ import { import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -82,6 +86,9 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { /** The `id` of the cipher in context */ @Input({ required: true }) cipherId: CipherId; + /** The organization ID if this cipher belongs to an organization */ + @Input() organizationId?: OrganizationId; + /** An optional submit button, whose loading/disabled state will be tied to the form state. */ @Input() submitBtn?: ButtonComponent; @@ -99,6 +106,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { private cipherDomain: Cipher; private activeUserId: UserId; + private isAdmin = false; private destroy$ = inject(DestroyRef); constructor( @@ -108,6 +116,8 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { private logService: LogService, private toastService: ToastService, private accountService: AccountService, + private apiService: ApiService, + private organizationService: OrganizationService, ) { this.attachmentForm.statusChanges.pipe(takeUntilDestroyed()).subscribe((status) => { if (!this.submitBtn) { @@ -120,7 +130,8 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { async ngOnInit(): Promise { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - this.cipherDomain = await this.cipherService.get(this.cipherId, this.activeUserId); + this.cipherDomain = await this.getCipher(this.cipherId); + this.cipher = await this.cipherDomain.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain, this.activeUserId), ); @@ -190,6 +201,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { this.cipherDomain, file, this.activeUserId, + this.isAdmin, ); // re-decrypt the cipher to update the attachments @@ -223,4 +235,50 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { this.onRemoveSuccess.emit(); } + + /** + * Gets a cipher using the appropriate method based on user permissions. + * If the user doesn't have direct access, but has organization admin access, + * it will retrieve the cipher using the admin endpoint. + */ + private async getCipher(id: CipherId): Promise { + if (id == null) { + return null; + } + + // First try to get the cipher directly with user permissions + const localCipher = await this.cipherService.get(id, this.activeUserId); + + // If we got the cipher or there's no organization context, return the result + if (localCipher != null || !this.organizationId) { + return localCipher; + } + + // Get the organization to check admin permissions + const organization = await this.getOrganization(); + // Only try the admin API if the user has admin permissions + if (organization != null && organization.canEditAllCiphers) { + this.isAdmin = true; + const cipherResponse = await this.apiService.getCipherAdmin(id); + const cipherData = new CipherData(cipherResponse); + return new Cipher(cipherData); + } + + return null; + } + + /** + * Gets the organization for the given organization ID + */ + private async getOrganization(): Promise { + if (!this.organizationId) { + return null; + } + + const organizations = await firstValueFrom( + this.organizationService.organizations$(this.activeUserId), + ); + + return organizations.find((o) => o.id === this.organizationId) || null; + } } diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html index 256082c2987..916430ec57b 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html @@ -6,6 +6,7 @@ { let fixture: ComponentFixture; const mockCipherId: CipherId = "cipher-id" as CipherId; + const mockOrganizationId: OrganizationId = "organization-id" as OrganizationId; const mockParams: AttachmentsDialogParams = { cipherId: mockCipherId, + organizationId: mockOrganizationId, }; beforeEach(async () => { @@ -34,6 +38,8 @@ describe("AttachmentsV2Component", () => { { provide: CipherService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: AccountService, useValue: mock() }, + { provide: ApiService, useValue: mock() }, + { provide: OrganizationService, useValue: mock() }, ], }).compileComponents(); @@ -42,9 +48,10 @@ describe("AttachmentsV2Component", () => { fixture.detectChanges(); }); - it("initializes without errors and with the correct cipherId", () => { + it("initializes without errors and with the correct cipherId and organizationId", () => { expect(component).toBeTruthy(); expect(component.cipherId).toBe(mockParams.cipherId); + expect(component.organizationId).toBe(mockParams.organizationId); }); it("closes the dialog with 'uploaded' result on uploadSuccessful", () => { diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index fd823353099..25cd4398664 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, Inject } from "@angular/core"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, DialogModule, @@ -17,6 +17,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm export interface AttachmentsDialogParams { cipherId: CipherId; + organizationId?: OrganizationId; } /** @@ -43,6 +44,7 @@ export interface AttachmentDialogCloseResult { }) export class AttachmentsV2Component { cipherId: CipherId; + organizationId?: OrganizationId; attachmentFormId = CipherAttachmentsComponent.attachmentFormID; /** @@ -55,6 +57,7 @@ export class AttachmentsV2Component { @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, ) { this.cipherId = params.cipherId; + this.organizationId = params.organizationId; } /**