From 80e6b1afd1907c45fdcb019d06b88d93f61db322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= Date: Thu, 17 Oct 2024 11:32:08 +0200 Subject: [PATCH 01/50] [BRE-101] Remove dept-devops from CODEOWNERS (#9564) * BRE-101: Remove dept-devops from CODEOWNERS * Fix * Update codeowners --- .github/CODEOWNERS | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7a7bb31ea5..ad802d791e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -114,18 +114,33 @@ apps/desktop/destkop_native/core/src/biometric/ @bitwarden/team-key-management-d apps/desktop/src/services/native-messaging.service.ts @bitwarden/team-key-management-dev apps/browser/src/background/nativeMessaging.background.ts @bitwarden/team-key-management-dev -## DevOps team files ## -/.github/workflows @bitwarden/dept-devops - -# DevOps for Docker changes. -**/Dockerfile @bitwarden/dept-devops -**/*.Dockerfile @bitwarden/dept-devops -**/.dockerignore @bitwarden/dept-devops -**/entrypoint.sh @bitwarden/dept-devops - ## Locales ## apps/browser/src/_locales/en/messages.json apps/browser/store/locales/en apps/cli/src/locales/en/messages.json apps/desktop/src/locales/en/messages.json apps/web/src/locales/en/messages.json + +## BRE team owns these workflows ## +.github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre +.github/workflows/deploy-web.yml @bitwarden/dept-bre +.github/workflows/publish-cli.yml @bitwarden/dept-bre +.github/workflows/publish-desktop.yml @bitwarden/dept-bre +.github/workflows/publish-web.yml @bitwarden/dept-bre +.github/workflows/retrieve-current-desktop-rollout.yml @bitwarden/dept-bre +.github/workflows/staged-rollout-desktop.yml @bitwarden/dept-bre + +## Shared ownership workflows ## +.github/workflows/release-browser.yml +.github/workflows/release-cli.yml +.github/workflows/release-desktop-beta.yml +.github/workflows/release-desktop.yml +.github/workflows/release-web.yml +.github/workflows/version-auto-bump.yml +.github/workflows/version-bump.yml + +## Docker files have shared ownership ## +**/Dockerfile +**/*.Dockerfile +**/.dockerignore +**/entrypoint.sh From 073ee4739b0844c9558be7dc45f202bb3bd0a850 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Thu, 17 Oct 2024 06:34:34 -0400 Subject: [PATCH 02/50] Split `Organization.LimitCollectionCreationDeletion` into two separate business rules (#11223) * Declare feature flag * Introduce new model properties * Reference feature toggle in template * Fix bugs caught during manual testing --- .../settings/account.component.html | 28 ++++++-- .../settings/account.component.ts | 69 +++++++++++++++---- apps/web/src/locales/en/messages.json | 6 ++ .../collections/models/collection.view.ts | 2 +- .../models/data/organization.data.spec.ts | 3 + .../models/data/organization.data.ts | 6 ++ .../models/domain/organization.ts | 11 ++- ...on-collection-management-update.request.ts | 3 + .../models/response/organization.response.ts | 6 ++ .../response/profile-organization.response.ts | 6 ++ .../services/key-connector.service.spec.ts | 3 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 12 files changed, 120 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index e4d3ee7de9..d1a1a09192 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -52,7 +52,11 @@

{{ "collectionManagement" | i18n }}

{{ "collectionManagementDesc" | i18n }}

@@ -60,12 +64,24 @@ {{ "allowAdminAccessToAllCollectionItemsDesc" | i18n }} - - {{ "limitCollectionCreationDeletionDesc" | i18n }} - - + + + {{ "limitCollectionCreationDesc" | i18n }} + + + + {{ "limitCollectionDeletionDesc" | i18n }} + + + + + + {{ "limitCollectionCreationDeletionDesc" | i18n }} + + + - - - - - -
-

{{ "noPasswordsInList" | i18n }}

-
+ + + + + +
+

{{ "noPasswordsInList" | i18n }}

+
diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts new file mode 100644 index 0000000000..8772a24582 --- /dev/null +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -0,0 +1,97 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { BehaviorSubject } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components"; +import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component"; + +import { PasswordHistoryViewComponent } from "./password-history-view.component"; + +describe("PasswordHistoryViewComponent", () => { + let component: PasswordHistoryViewComponent; + let fixture: ComponentFixture; + + const mockCipher = { + id: "122-333-444", + type: CipherType.Login, + organizationId: "222-444-555", + } as CipherView; + + const copyToClipboard = jest.fn(); + const showToast = jest.fn(); + const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" }); + const mockCipherService = { + get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }), + getKeyForCipherKeyDecryption: jest.fn().mockResolvedValue({}), + }; + + beforeEach(async () => { + mockCipherService.get.mockClear(); + mockCipherService.getKeyForCipherKeyDecryption.mockClear(); + copyToClipboard.mockClear(); + showToast.mockClear(); + + await TestBed.configureTestingModule({ + imports: [ItemModule, ColorPasswordModule, JslibModule], + providers: [ + { provide: WINDOW, useValue: window }, + { provide: CipherService, useValue: mockCipherService }, + { provide: PlatformUtilsService, useValue: { copyToClipboard } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: ToastService, useValue: { showToast } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PasswordHistoryViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("renders no history text when history does not exist", () => { + expect(fixture.debugElement.nativeElement.textContent).toBe("noPasswordsInList"); + }); + + describe("history", () => { + const password1 = { password: "bad-password-1", lastUsedDate: new Date("09/13/2004") }; + const password2 = { password: "bad-password-2", lastUsedDate: new Date("02/01/2004") }; + + beforeEach(async () => { + mockCipher.passwordHistory = [password1, password2]; + + mockCipherService.get.mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }); + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("renders all passwords", () => { + const passwords = fixture.debugElement.queryAll(By.directive(ColorPasswordComponent)); + + expect(passwords.map((password) => password.componentInstance.password)).toEqual([ + "bad-password-1", + "bad-password-2", + ]); + }); + + it("copies a password", () => { + const copyButton = fixture.debugElement.query(By.css("button")); + + copyButton.nativeElement.click(); + + expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window }); + expect(showToast).toHaveBeenCalledWith({ + message: "passwordCopied", + title: "", + variant: "info", + }); + }); + }); +}); diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.ts b/libs/vault/src/components/password-history-view/password-history-view.component.ts new file mode 100644 index 0000000000..5e858af727 --- /dev/null +++ b/libs/vault/src/components/password-history-view/password-history-view.component.ts @@ -0,0 +1,77 @@ +import { CommonModule } from "@angular/common"; +import { OnInit, Inject, Component, Input } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; +import { + ToastService, + ItemModule, + ColorPasswordModule, + IconButtonModule, +} from "@bitwarden/components"; + +@Component({ + selector: "vault-password-history-view", + templateUrl: "./password-history-view.component.html", + standalone: true, + imports: [CommonModule, ItemModule, ColorPasswordModule, IconButtonModule, JslibModule], +}) +export class PasswordHistoryViewComponent implements OnInit { + /** + * The ID of the cipher to display the password history for. + */ + @Input({ required: true }) cipherId: CipherId; + + /** The password history for the cipher. */ + history: PasswordHistoryView[] = []; + + constructor( + @Inject(WINDOW) private win: Window, + protected cipherService: CipherService, + protected platformUtilsService: PlatformUtilsService, + protected i18nService: I18nService, + protected accountService: AccountService, + protected toastService: ToastService, + ) {} + + async ngOnInit() { + await this.init(); + } + + /** Copies a password to the clipboard. */ + copy(password: string) { + const copyOptions = this.win != null ? { window: this.win } : undefined; + this.platformUtilsService.copyToClipboard(password, copyOptions); + this.toastService.showToast({ + variant: "info", + title: "", + message: this.i18nService.t("passwordCopied"), + }); + } + + /** Retrieve the password history for the given cipher */ + protected async init() { + const cipher = await this.cipherService.get(this.cipherId); + const activeAccount = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a: { id: string | undefined }) => a)), + ); + + if (!activeAccount?.id) { + throw new Error("Active account is not available."); + } + + const activeUserId = activeAccount.id as UserId; + const decCipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + + this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index d5841c7db0..f6a95281f8 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -12,5 +12,6 @@ export { } from "./components/assign-collections.component"; export { DownloadAttachmentComponent } from "./components/download-attachment/download-attachment.component"; +export { PasswordHistoryViewComponent } from "./components/password-history-view/password-history-view.component"; export * as VaultIcons from "./icons"; From 80a4fba7871d2133a5f799c52686adcfe84e10d2 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:57:34 -0500 Subject: [PATCH 27/50] Allow for the web generation dialog to disable the margin of the tools generation components (#11565) --- .../web-generator-dialog/web-generator-dialog.component.html | 1 + .../components/src/passphrase-settings.component.html | 2 +- .../generator/components/src/passphrase-settings.component.ts | 4 ++++ .../components/src/password-generator.component.html | 2 ++ .../generator/components/src/password-generator.component.ts | 4 ++++ .../generator/components/src/password-settings.component.html | 2 +- .../generator/components/src/password-settings.component.ts | 4 ++++ .../components/src/username-generator.component.html | 4 ++-- .../generator/components/src/username-generator.component.ts | 4 ++++ .../cipher-generator/cipher-form-generator.component.html | 2 ++ .../cipher-generator/cipher-form-generator.component.ts | 4 ++++ 11 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html index afe62cdc8a..e224d1d19c 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html @@ -6,6 +6,7 @@ diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index c40df97c69..2a3f4b5a28 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -1,4 +1,4 @@ - +
{{ "options" | i18n }}
diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 25e028210c..82524eba4d 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -1,3 +1,4 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, skip, takeUntil, Subject } from "rxjs"; @@ -47,6 +48,9 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { @Input() showHeader: boolean = true; + /** Removes bottom margin from `bit-section` */ + @Input({ transform: coerceBooleanProperty }) disableMargin = false; + /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 9a33aa143e..b4cf8c6cdb 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -32,6 +32,7 @@ class="tw-mt-6" *ngIf="(algorithm$ | async)?.id === 'password'" [userId]="this.userId$ | async" + [disableMargin]="disableMargin" (onUpdated)="generate$.next()" /> diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index bf33c7cfca..e3f9073cb1 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -1,3 +1,4 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { BehaviorSubject, @@ -45,6 +46,9 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { @Input() userId: UserId | null; + /** Removes bottom margin, passed to downstream components */ + @Input({ transform: coerceBooleanProperty }) disableMargin = false; + /** tracks the currently selected credential type */ protected credentialType$ = new BehaviorSubject(null); diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html index fcafc78904..9c4fb59539 100644 --- a/libs/tools/generator/components/src/password-settings.component.html +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -1,4 +1,4 @@ - +

{{ "options" | i18n }}

diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 9466c81a0f..2a8bff31c4 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,3 +1,4 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, takeUntil, Subject, map, filter, tap, debounceTime, skip } from "rxjs"; @@ -55,6 +56,9 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { @Input() waitMs: number = 100; + /** Removes bottom margin from `bit-section` */ + @Input({ transform: coerceBooleanProperty }) disableMargin = false; + /** Emits settings updates and completes if the settings become unavailable. * @remarks this does not emit the initial settings. If you would like * to receive live settings updates including the initial update, diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 6425cb7a38..e9d7d1c1f8 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -17,11 +17,11 @@ - +
{{ "options" | i18n }}
-
+
diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 767c73c398..fd1a21cc3e 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -1,3 +1,4 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { @@ -57,6 +58,9 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { @Output() readonly onGenerated = new EventEmitter(); + /** Removes bottom margin from internal elements */ + @Input({ transform: coerceBooleanProperty }) disableMargin = false; + /** Tracks the selected generation algorithm */ protected credential = this.formBuilder.group({ type: [null as CredentialAlgorithm], diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html index 181ca50da8..445908679c 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html @@ -1,8 +1,10 @@ diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts index db6e9ae106..79fac29d4d 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -1,3 +1,4 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output } from "@angular/core"; @@ -21,6 +22,9 @@ export class CipherFormGeneratorComponent { @Input({ required: true }) type: "password" | "username"; + /** Removes bottom margin of internal sections */ + @Input({ transform: coerceBooleanProperty }) disableMargin = false; + /** * Emits an event when a new value is generated. */ From 82547573752a0e5ecbdcd8094c45207a0a46bc48 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 18 Oct 2024 16:28:42 -0700 Subject: [PATCH 28/50] [PM-13453] - Health report raw data with member count component (#11573) * add raw data + members table * remove commented code --- .../access-intelligence.component.html | 3 + .../access-intelligence.component.ts | 2 + .../password-health-members.component.html | 61 +++++ .../password-health-members.component.ts | 233 ++++++++++++++++++ .../password-health.mock.ts | 66 +++++ 5 files changed, 365 insertions(+) create mode 100644 apps/web/src/app/tools/access-intelligence/password-health-members.component.html create mode 100644 apps/web/src/app/tools/access-intelligence/password-health-members.component.ts create mode 100644 apps/web/src/app/tools/access-intelligence/password-health.mock.ts diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html index df3eee389f..78ddfb2392 100644 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html @@ -3,6 +3,9 @@ + + + - {{ "noMatchingLoginsForSite" | i18n }} - {{ "searchSavePasskeyNewLogin" | i18n }} + {{ + (hasSearched ? "noItemsMatchSearch" : "noMatchingLoginsForSite") | i18n + }} + {{ + (hasSearched ? "searchSavePasskeyNewLogin" : "clearFiltersOrTryAnother") | i18n + }} + @@ -100,17 +105,22 @@ - {{ "noItemsMatchSearch" | i18n }} - {{ "clearFiltersOrTryAnother" | i18n }} + {{ + (hasSearched ? "noItemsMatchSearch" : "noMatchingLoginsForSite") | i18n + }} + {{ + (hasSearched ? "searchSavePasskeyNewLogin" : "clearFiltersOrTryAnother") | i18n + }} + diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index cf0fd90a8f..82be95ea0d 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -91,7 +91,6 @@ interface ViewData { export class Fido2Component implements OnInit, OnDestroy { private destroy$ = new Subject(); private message$ = new BehaviorSubject(null); - private hasSearched = false; protected BrowserFido2MessageTypes = BrowserFido2MessageTypes; protected cipher: CipherView; protected ciphers?: CipherView[] = []; @@ -104,6 +103,7 @@ export class Fido2Component implements OnInit, OnDestroy { protected noResultsIcon = Icons.NoResults; protected passkeyAction: PasskeyActionValue = PasskeyActions.Register; protected PasskeyActions = PasskeyActions; + protected hasSearched = false; protected searchText: string; protected searchTypeSearch = false; protected senderTabId?: string; @@ -370,19 +370,30 @@ export class Fido2Component implements OnInit, OnDestroy { return this.equivalentDomains; } + async clearSearch() { + this.searchText = ""; + await this.setDisplayedCiphersToAllDomainMatch(); + } + + protected async setDisplayedCiphersToAllDomainMatch() { + const equivalentDomains = await this.getEquivalentDomains(); + this.displayedCiphers = this.ciphers.filter((cipher) => + cipher.login.matchesUri(this.url, equivalentDomains), + ); + } + protected async search() { - this.hasSearched = await this.searchService.isSearchable(this.searchText); - if (this.hasSearched) { + this.hasSearched = true; + const isSearchable = await this.searchService.isSearchable(this.searchText); + + if (isSearchable) { this.displayedCiphers = await this.searchService.searchCiphers( this.searchText, null, this.ciphers, ); } else { - const equivalentDomains = await this.getEquivalentDomains(); - this.displayedCiphers = this.ciphers.filter((cipher) => - cipher.login.matchesUri(this.url, equivalentDomains), - ); + await this.setDisplayedCiphersToAllDomainMatch(); } } From c9de05de95220db60f967432d4fbc798b4d6a0e6 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 21 Oct 2024 09:50:59 -0400 Subject: [PATCH 33/50] [PM-13675] Adjust browser autofill override instructions conditions and placement in the settings view (#11559) * adjust browser autofill override instructions conditions and placement in the settings view * adjust placement of override instructions in the refresh component for Firefox --- .../popup/settings/autofill-v1.component.html | 17 ++++++++-- .../popup/settings/autofill.component.html | 31 ++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/browser/src/autofill/popup/settings/autofill-v1.component.html b/apps/browser/src/autofill/popup/settings/autofill-v1.component.html index ec8aeac37e..530519e88f 100644 --- a/apps/browser/src/autofill/popup/settings/autofill-v1.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill-v1.component.html @@ -41,8 +41,19 @@
-
@@ -86,7 +97,7 @@ /> - -
+
-
+
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 38416c2c39..ae2cf88fd1 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 @@ -2,7 +2,7 @@ import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, Subject } from "rxjs"; +import { firstValueFrom, Observable, Subject } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -12,12 +12,13 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs 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 } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId } 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"; import { 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 { AsyncActionsModule, ButtonModule, @@ -63,6 +64,16 @@ export interface VaultItemDialogParams { * If true, the "edit" button will be disabled in the dialog. */ disableForm?: boolean; + + /** + * The ID of the active collection. This is know the collection filter selected by the user. + */ + activeCollectionId?: CollectionId; + + /** + * If true, the dialog is being opened from the admin console. + */ + isAdminConsoleAction?: boolean; } export enum VaultItemDialogResult { @@ -204,6 +215,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected formConfig: CipherFormConfig = this.params.formConfig; + protected canDeleteCipher$: Observable; + constructor( @Inject(DIALOG_DATA) protected params: VaultItemDialogParams, private dialogRef: DialogRef, @@ -217,6 +230,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private router: Router, private billingAccountProfileStateService: BillingAccountProfileStateService, private premiumUpgradeService: PremiumUpgradePromptService, + private cipherAuthorizationService: CipherAuthorizationService, ) { this.updateTitle(); } @@ -231,6 +245,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.organization = this.formConfig.organizations.find( (o) => o.id === this.cipher.organizationId, ); + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$( + this.cipher, + [this.params.activeCollectionId], + this.params.isAdminConsoleAction, + ); } this.performingInitialLoad = false; diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 2f38d7c70d..286bbbab5e 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -132,7 +132,7 @@ {{ "restore" | i18n }} - - diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index 0dd58b846d..d1bfd22117 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -14,6 +14,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component"; @@ -62,6 +63,12 @@ describe("ViewComponent", () => { useValue: mock(), }, { provide: ConfigService, useValue: mock() }, + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn().mockReturnValue(true), + }, + }, ], }).compileComponents(); diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index 99829e8f08..d30c453a4b 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -1,6 +1,7 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Inject, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -8,10 +9,12 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga 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 { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AsyncActionsModule, DialogModule, @@ -34,6 +37,11 @@ export interface ViewCipherDialogParams { */ collections?: CollectionView[]; + /** + * Optional collection ID used to know the collection filter selected. + */ + activeCollectionId?: CollectionId; + /** * If true, the edit button will be disabled in the dialog. */ @@ -71,6 +79,8 @@ export class ViewComponent implements OnInit { cipherTypeString: string; organization: Organization; + canDeleteCipher$: Observable; + constructor( @Inject(DIALOG_DATA) public params: ViewCipherDialogParams, private dialogRef: DialogRef, @@ -81,6 +91,7 @@ export class ViewComponent implements OnInit { private cipherService: CipherService, private toastService: ToastService, private organizationService: OrganizationService, + private cipherAuthorizationService: CipherAuthorizationService, ) {} /** @@ -93,6 +104,10 @@ export class ViewComponent implements OnInit { if (this.cipher.organizationId) { this.organization = await this.organizationService.get(this.cipher.organizationId); } + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.params.activeCollectionId, + ]); } /** diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 9cb5542a7b..7a4697f5af 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -21,6 +21,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -57,6 +58,7 @@ export class AddEditComponent extends BaseAddEditComponent { datePipe: DatePipe, configService: ConfigService, billingAccountProfileStateService: BillingAccountProfileStateService, + cipherAuthorizationService: CipherAuthorizationService, ) { super( cipherService, @@ -79,6 +81,7 @@ export class AddEditComponent extends BaseAddEditComponent { datePipe, configService, billingAccountProfileStateService, + cipherAuthorizationService, ); } @@ -90,6 +93,7 @@ export class AddEditComponent extends BaseAddEditComponent { } protected async loadCipher() { + this.isAdminConsoleAction = true; // Calling loadCipher first to assess if the cipher is unassigned. If null use apiService getCipherAdmin const firstCipherCheck = await super.loadCipher(); diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 220d6ef490..0bcdc52eae 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -70,6 +70,7 @@ [viewingOrgVault]="true" [addAccessStatus]="addAccessStatus$ | async" [addAccessToggle]="showAddAccessToggle" + [activeCollection]="selectedCollection?.node" > diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 94bb6011dc..060ff7824d 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -828,6 +828,7 @@ export class VaultComponent implements OnInit, OnDestroy { comp.organization = this.organization; comp.organizationId = this.organization.id; comp.cipherId = cipher?.id; + comp.collectionId = this.activeFilter.collectionId; comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); this.refresh(); @@ -897,7 +898,12 @@ export class VaultComponent implements OnInit, OnDestroy { cipher.type, ); - await this.openVaultItemDialog("view", cipherFormConfig, cipher); + await this.openVaultItemDialog( + "view", + cipherFormConfig, + cipher, + this.activeFilter.collectionId as CollectionId, + ); } /** @@ -907,6 +913,7 @@ export class VaultComponent implements OnInit, OnDestroy { mode: VaultItemDialogMode, formConfig: CipherFormConfig, cipher?: CipherView, + activeCollectionId?: CollectionId, ) { const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false; // If the form is disabled, force the mode into `view` @@ -915,6 +922,8 @@ export class VaultComponent implements OnInit, OnDestroy { mode: dialogMode, formConfig, disableForm, + activeCollectionId, + isAdminConsoleAction: true, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6af0fe2f66..e8d29bd69b 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -242,6 +242,10 @@ import { } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -1340,6 +1344,11 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, ], }), + safeProvider({ + provide: CipherAuthorizationService, + useClass: DefaultCipherAuthorizationService, + deps: [CollectionService, OrganizationServiceAbstraction], + }), ]; @NgModule({ diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 49129a868b..44eaec03a6 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -23,7 +23,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { UserId } from "@bitwarden/common/types/guid"; +import { CollectionId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; @@ -36,6 +36,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -47,6 +48,7 @@ export class AddEditComponent implements OnInit, OnDestroy { @Input() type: CipherType; @Input() collectionIds: string[]; @Input() organizationId: string = null; + @Input() collectionId: string = null; @Output() onSavedCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); @Output() onRestoredCipher = new EventEmitter(); @@ -57,6 +59,8 @@ export class AddEditComponent implements OnInit, OnDestroy { @Output() onGeneratePassword = new EventEmitter(); @Output() onGenerateUsername = new EventEmitter(); + canDeleteCipher$: Observable; + editMode = false; cipher: CipherView; folders$: Observable; @@ -83,6 +87,10 @@ export class AddEditComponent implements OnInit, OnDestroy { reprompt = false; canUseReprompt = true; organization: Organization; + /** + * Flag to determine if the action is being performed from the admin console. + */ + isAdminConsoleAction: boolean = false; protected componentName = ""; protected destroy$ = new Subject(); @@ -118,6 +126,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected win: Window, protected datePipe: DatePipe, protected configService: ConfigService, + protected cipherAuthorizationService: CipherAuthorizationService, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -314,6 +323,12 @@ export class AddEditComponent implements OnInit, OnDestroy { if (this.reprompt) { this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; } + + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$( + this.cipher, + [this.collectionId as CollectionId], + this.isAdminConsoleAction, + ); } async submit(): Promise { diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index ac644acf9e..4c96c10dac 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -9,7 +9,7 @@ import { OnInit, Output, } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom, map, Observable } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -28,6 +28,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -37,6 +38,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -45,12 +47,14 @@ const BroadcasterSubscriptionId = "ViewComponent"; @Directive() export class ViewComponent implements OnDestroy, OnInit { @Input() cipherId: string; + @Input() collectionId: string; @Output() onEditCipher = new EventEmitter(); @Output() onCloneCipher = new EventEmitter(); @Output() onShareCipher = new EventEmitter(); @Output() onDeletedCipher = new EventEmitter(); @Output() onRestoredCipher = new EventEmitter(); + canDeleteCipher$: Observable; cipher: CipherView; showPassword: boolean; showPasswordCount: boolean; @@ -105,6 +109,7 @@ export class ViewComponent implements OnDestroy, OnInit { protected datePipe: DatePipe, protected accountService: AccountService, private billingAccountProfileStateService: BillingAccountProfileStateService, + private cipherAuthorizationService: CipherAuthorizationService, ) {} ngOnInit() { @@ -144,6 +149,9 @@ export class ViewComponent implements OnDestroy, OnInit { ); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.collectionId as CollectionId, + ]); if (this.cipher.folderId) { this.folder = await ( diff --git a/libs/common/src/vault/services/cipher-authorization.service.spec.ts b/libs/common/src/vault/services/cipher-authorization.service.spec.ts new file mode 100644 index 0000000000..3155825d4d --- /dev/null +++ b/libs/common/src/vault/services/cipher-authorization.service.spec.ts @@ -0,0 +1,200 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { CipherView } from "../models/view/cipher.view"; + +import { + CipherAuthorizationService, + DefaultCipherAuthorizationService, +} from "./cipher-authorization.service"; + +describe("CipherAuthorizationService", () => { + let cipherAuthorizationService: CipherAuthorizationService; + + const mockCollectionService = mock(); + const mockOrganizationService = mock(); + + // Mock factories + const createMockCipher = ( + organizationId: string | null, + collectionIds: string[], + edit: boolean = true, + ) => ({ + organizationId, + collectionIds, + edit, + }); + + const createMockCollection = (id: string, manage: boolean) => ({ + id, + manage, + }); + + const createMockOrganization = ({ + allowAdminAccessToAllCollectionItems = false, + canEditAllCiphers = false, + canEditUnassignedCiphers = false, + } = {}) => ({ + allowAdminAccessToAllCollectionItems, + canEditAllCiphers, + canEditUnassignedCiphers, + }); + + beforeEach(() => { + jest.clearAllMocks(); + cipherAuthorizationService = new DefaultCipherAuthorizationService( + mockCollectionService, + mockOrganizationService, + ); + }); + + describe("canDeleteCipher$", () => { + it("should return true if cipher has no organizationId", (done) => { + const cipher = createMockCipher(null, []) as CipherView; + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and cipher is unassigned", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: true }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return true if isAdminConsoleAction is true and user can edit all ciphers in the org", (done) => { + const cipher = createMockCipher("org1", ["col1"]) as CipherView; + const organization = createMockOrganization({ canEditAllCiphers: true }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(true); + expect(mockOrganizationService.get$).toHaveBeenCalledWith("org1"); + done(); + }); + }); + + it("should return false if isAdminConsoleAction is true but user does not have permission to edit unassigned ciphers", (done) => { + const cipher = createMockCipher("org1", []) as CipherView; + const organization = createMockOrganization({ canEditUnassignedCiphers: false }); + mockOrganizationService.get$.mockReturnValue(of(organization as Organization)); + + cipherAuthorizationService.canDeleteCipher$(cipher, [], true).subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return true if activeCollectionId is provided and has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const activeCollectionId = "col1" as CollectionId; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", true), + createMockCollection("col2", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService + .canDeleteCipher$(cipher, [activeCollectionId]) + .subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + + it("should return false if activeCollectionId is provided and manage permission is not present", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const activeCollectionId = "col1" as CollectionId; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", true), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService + .canDeleteCipher$(cipher, [activeCollectionId]) + .subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + + it("should return true if any collection has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2", "col3"]) as CipherView; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", true), + createMockCollection("col3", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(true); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + "col3", + ] as CollectionId[]); + done(); + }); + }); + + it("should return false if no collection has manage permission", (done) => { + const cipher = createMockCipher("org1", ["col1", "col2"]) as CipherView; + const org = createMockOrganization(); + mockOrganizationService.get$.mockReturnValue(of(org as Organization)); + + const allCollections = [ + createMockCollection("col1", false), + createMockCollection("col2", false), + ]; + mockCollectionService.decryptedCollectionViews$.mockReturnValue( + of(allCollections as CollectionView[]), + ); + + cipherAuthorizationService.canDeleteCipher$(cipher).subscribe((result) => { + expect(result).toBe(false); + expect(mockCollectionService.decryptedCollectionViews$).toHaveBeenCalledWith([ + "col1", + "col2", + ] as CollectionId[]); + done(); + }); + }); + }); +}); diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts new file mode 100644 index 0000000000..00c7c412d6 --- /dev/null +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -0,0 +1,86 @@ +import { map, Observable, of, switchMap } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { Cipher } from "../models/domain/cipher"; +import { CipherView } from "../models/view/cipher.view"; + +/** + * Represents either a cipher or a cipher view. + */ +type CipherLike = Cipher | CipherView; + +/** + * Service for managing user cipher authorization. + */ +export abstract class CipherAuthorizationService { + /** + * Determines if the user can delete the specified cipher. + * + * @param {CipherLike} cipher - The cipher object to evaluate for deletion permissions. + * @param {CollectionId[]} [allowedCollections] - Optional. The selected collection id from the vault filter. + * @param {boolean} isAdminConsoleAction - Optional. A flag indicating if the action is being performed from the admin console. + * + * @returns {Observable} - An observable that emits a boolean value indicating if the user can delete the cipher. + */ + canDeleteCipher$: ( + cipher: CipherLike, + allowedCollections?: CollectionId[], + isAdminConsoleAction?: boolean, + ) => Observable; +} + +/** + * {@link CipherAuthorizationService} + */ +export class DefaultCipherAuthorizationService implements CipherAuthorizationService { + constructor( + private collectionService: CollectionService, + private organizationService: OrganizationService, + ) {} + + /** + * + * {@link CipherAuthorizationService.canDeleteCipher$} + */ + canDeleteCipher$( + cipher: CipherLike, + allowedCollections?: CollectionId[], + isAdminConsoleAction?: boolean, + ): Observable { + if (cipher.organizationId == null) { + return of(true); + } + + return this.organizationService.get$(cipher.organizationId).pipe( + switchMap((organization) => { + if (isAdminConsoleAction) { + // If the user is an admin, they can delete an unassigned cipher + if (!cipher.collectionIds || cipher.collectionIds.length === 0) { + return of(organization?.canEditUnassignedCiphers === true); + } + + if (organization?.canEditAllCiphers) { + return of(true); + } + } + + return this.collectionService + .decryptedCollectionViews$(cipher.collectionIds as CollectionId[]) + .pipe( + map((allCollections) => { + const shouldFilter = allowedCollections?.some(Boolean); + + const collections = shouldFilter + ? allCollections.filter((c) => allowedCollections.includes(c.id as CollectionId)) + : allCollections; + + return collections.some((collection) => collection.manage); + }), + ); + }), + ); + } +} From 023abe2969068a7fd229e70e3a81d76fc4476b4d Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 22 Oct 2024 10:07:22 -0400 Subject: [PATCH 47/50] [PM-11199] added permission labels to ciphers in AC (#11210) * added permission labels to ciphers in AC --- .../vault-cipher-row.component.html | 6 ++- .../vault-items/vault-cipher-row.component.ts | 52 ++++++++++++++++++- .../app/vault/org-vault/vault.component.ts | 1 + 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 286bbbab5e..5c4de576ea 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -69,7 +69,11 @@ > - + +

+ {{ permissionText }} +

+