1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-16 01:21:48 +01:00

[PM-13892] Browser Refresh - Organization item clone permission fix (#11660)

* [PM-13892] Introduce canClone$ method on CipherAuthorizationService

* [PM-13892] Use new canClone$ method for the 3dot menu in browser extension

* [PM-13892] Add todo for vault-items.component.ts
This commit is contained in:
Shane Melton 2024-10-24 14:12:04 -07:00 committed by GitHub
parent 81d7f319f6
commit a0fe4f4ca6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 120 additions and 4 deletions

View File

@ -22,7 +22,7 @@
{{ favoriteText | i18n }}
</button>
<ng-container *ngIf="canEdit">
<a bitMenuItem (click)="clone()">
<a bitMenuItem (click)="clone()" *ngIf="canClone$ | async">
{{ "clone" | i18n }}
</a>
<a bitMenuItem *ngIf="hasOrganizations" (click)="conditionallyNavigateToAssignCollections()">

View File

@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input, OnInit } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -10,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
DialogService,
IconButtonModule,
@ -42,6 +43,7 @@ export class ItemMoreOptionsComponent implements OnInit {
hideAutofillOptions: boolean;
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
protected canClone$: Observable<boolean>;
/** Boolean dependent on the current user having access to an organization */
protected hasOrganizations = false;
@ -56,10 +58,12 @@ export class ItemMoreOptionsComponent implements OnInit {
private vaultPopupAutofillService: VaultPopupAutofillService,
private accountService: AccountService,
private organizationService: OrganizationService,
private cipherAuthorizationService: CipherAuthorizationService,
) {}
async ngOnInit(): Promise<void> {
this.hasOrganizations = await this.organizationService.hasOrganizations();
this.canClone$ = this.cipherAuthorizationService.canCloneCipher$(this.cipher);
}
get canEdit() {

View File

@ -194,6 +194,7 @@ export class VaultItemsComponent {
});
}
// TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead
protected canClone(vaultItem: VaultItem) {
if (vaultItem.cipher.organizationId == null) {
return true;

View File

@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -39,10 +39,16 @@ describe("CipherAuthorizationService", () => {
allowAdminAccessToAllCollectionItems = false,
canEditAllCiphers = false,
canEditUnassignedCiphers = false,
isAdmin = false,
editAnyCollection = false,
} = {}) => ({
allowAdminAccessToAllCollectionItems,
canEditAllCiphers,
canEditUnassignedCiphers,
isAdmin,
permissions: {
editAnyCollection,
},
});
beforeEach(() => {
@ -197,4 +203,73 @@ describe("CipherAuthorizationService", () => {
});
});
});
describe("canCloneCipher$", () => {
it("should return true if cipher has no organizationId", async () => {
const cipher = createMockCipher(null, []) as CipherView;
const result = await firstValueFrom(cipherAuthorizationService.canCloneCipher$(cipher));
expect(result).toBe(true);
});
describe("isAdminConsoleAction is true", () => {
it("should return true for admin users", async () => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ isAdmin: true });
mockOrganizationService.get$.mockReturnValue(of(organization as Organization));
const result = await firstValueFrom(
cipherAuthorizationService.canCloneCipher$(cipher, true),
);
expect(result).toBe(true);
});
it("should return true for custom user with canEditAnyCollection", async () => {
const cipher = createMockCipher("org1", []) as CipherView;
const organization = createMockOrganization({ editAnyCollection: true });
mockOrganizationService.get$.mockReturnValue(of(organization as Organization));
const result = await firstValueFrom(
cipherAuthorizationService.canCloneCipher$(cipher, true),
);
expect(result).toBe(true);
});
});
describe("isAdminConsoleAction is false", () => {
it("should return true if at least one cipher collection has manage permission", async () => {
const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.get$.mockReturnValue(of(organization as Organization));
const allCollections = [
createMockCollection("col1", true),
createMockCollection("col2", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
of(allCollections as CollectionView[]),
);
const result = await firstValueFrom(cipherAuthorizationService.canCloneCipher$(cipher));
expect(result).toBe(true);
});
it("should return false if no collection has manage permission", async () => {
const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView;
const organization = createMockOrganization();
mockOrganizationService.get$.mockReturnValue(of(organization as Organization));
const allCollections = [
createMockCollection("col1", false),
createMockCollection("col2", false),
];
mockCollectionService.decryptedCollectionViews$.mockReturnValue(
of(allCollections as CollectionView[]),
);
const result = await firstValueFrom(cipherAuthorizationService.canCloneCipher$(cipher));
expect(result).toBe(false);
});
});
});
});

View File

@ -1,4 +1,4 @@
import { map, Observable, of, switchMap } from "rxjs";
import { map, Observable, of, shareReplay, switchMap } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@ -30,6 +30,16 @@ export abstract class CipherAuthorizationService {
allowedCollections?: CollectionId[],
isAdminConsoleAction?: boolean,
) => Observable<boolean>;
/**
* Determines if the user can clone the specified cipher.
*
* @param {CipherLike} cipher - The cipher object to evaluate for cloning permissions.
* @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console.
*
* @returns {Observable<boolean>} - An observable that emits a boolean value indicating if the user can clone the cipher.
*/
canCloneCipher$: (cipher: CipherLike, isAdminConsoleAction?: boolean) => Observable<boolean>;
}
/**
@ -83,4 +93,30 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer
}),
);
}
/**
* {@link CipherAuthorizationService.canCloneCipher$}
*/
canCloneCipher$(cipher: CipherLike, isAdminConsoleAction?: boolean): Observable<boolean> {
if (cipher.organizationId == null) {
return of(true);
}
return this.organizationService.get$(cipher.organizationId).pipe(
switchMap((organization) => {
// Admins and custom users can always clone when in the Admin Console
if (
isAdminConsoleAction &&
(organization.isAdmin || organization.permissions?.editAnyCollection)
) {
return of(true);
}
return this.collectionService
.decryptedCollectionViews$(cipher.collectionIds as CollectionId[])
.pipe(map((allCollections) => allCollections.some((collection) => collection.manage)));
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
}
}