diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 331d72ce73..a8767a4edc 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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"; @@ -29,7 +30,7 @@ import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service"; import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service"; import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data"; -import { VaultPopoutType, closeAddEditVaultItemPopout } from "../../utils/vault-popout-window"; +import { closeAddEditVaultItemPopout, VaultPopoutType } from "../../utils/vault-popout-window"; @Component({ selector: "app-vault-add-edit", @@ -66,6 +67,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, + configService: ConfigServiceAbstraction, ) { super( cipherService, @@ -85,6 +87,7 @@ export class AddEditComponent extends BaseAddEditComponent { dialogService, window, datePipe, + configService, ); } diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index e6976503e7..8532b7462a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -8,6 +8,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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"; @@ -48,6 +49,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnChanges, sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, + configService: ConfigServiceAbstraction, ) { super( cipherService, @@ -67,6 +69,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnChanges, dialogService, window, datePipe, + configService, ); } diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts index 146d12e734..6c261f9a05 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts @@ -5,6 +5,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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"; @@ -50,6 +51,7 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, + configService: ConfigServiceAbstraction, ) { super( cipherService, @@ -70,6 +72,7 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { sendApiService, dialogService, datePipe, + configService, ); } diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 5376a68922..00464882ae 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -7,6 +7,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { EventType, ProductType } from "@bitwarden/common/enums"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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"; @@ -62,6 +63,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, + configService: ConfigServiceAbstraction, ) { super( cipherService, @@ -81,6 +83,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On dialogService, window, datePipe, + configService, ); } 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 551e21263d..567dcf05df 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 @@ -6,6 +6,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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"; @@ -52,6 +53,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, + configService: ConfigServiceAbstraction, ) { super( cipherService, @@ -72,6 +74,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService, dialogService, datePipe, + configService, ); } @@ -81,7 +84,9 @@ export class AddEditComponent extends BaseAddEditComponent { (this.ownershipOptions.length > 1 || !this.allowPersonal) ) { if (this.organization != null) { - return this.cloneMode && this.organization.canEditAnyCollection; + return ( + this.cloneMode && this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) + ); } else { return !this.editMode || this.cloneMode; } @@ -90,14 +95,14 @@ export class AddEditComponent extends BaseAddEditComponent { } protected loadCollections() { - if (!this.organization.canEditAnyCollection) { + if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { return super.loadCollections(); } return Promise.resolve(this.collections); } protected async loadCipher() { - if (!this.organization.canEditAnyCollection) { + if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { return await super.loadCipher(); } const response = await this.apiService.getCipherAdmin(this.cipherId); @@ -110,14 +115,14 @@ export class AddEditComponent extends BaseAddEditComponent { } protected encryptCipher() { - if (!this.organization.canEditAnyCollection) { + if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { return super.encryptCipher(); } return this.cipherService.encrypt(this.cipher, null, null, this.originalCipher); } protected async deleteCipher() { - if (!this.organization.canEditAnyCollection) { + if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { return super.deleteCipher(); } return this.cipher.isDeleted diff --git a/apps/web/src/app/vault/org-vault/collection-access-restricted.component.ts b/apps/web/src/app/vault/org-vault/collection-access-restricted.component.ts index 43583de18d..c0598b1f22 100644 --- a/apps/web/src/app/vault/org-vault/collection-access-restricted.component.ts +++ b/apps/web/src/app/vault/org-vault/collection-access-restricted.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Output } from "@angular/core"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components"; @@ -13,9 +13,10 @@ const icon = svgIcon` + template: ` {{ "collectionAccessRestricted" | i18n }} - + +

{{ "noPermissionToViewAllCollectionItems" | i18n }}

+ +
+ +

{{ "noItemsInList" | i18n }}

+ +
+ + + + {{ "noItemsInList" | i18n }} + + + + +
| undefined; protected isEmpty: boolean; + /** + * Used to show an old missing permission message for custom users with DeleteAnyCollection + * @deprecated Replaced with showCollectionAccessRestricted$ and this should be removed after flexible collections V1 + * is released + */ protected showMissingCollectionPermissionMessage: boolean; + protected showCollectionAccessRestricted: boolean; protected currentSearchText$: Observable; + protected editableCollections$: Observable; protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$( FeatureFlag.BulkCollectionAccess, false, ); - protected flexibleCollectionsEnabled: boolean; + protected flexibleCollectionsV1Enabled: boolean; private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); @@ -176,6 +183,11 @@ export class VaultComponent implements OnInit, OnDestroy { : "trashCleanupWarning", ); + this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag( + FeatureFlag.FlexibleCollectionsV1, + false, + ); + const filter$ = this.routedVaultFilterService.filter$; const organizationId$ = filter$.pipe( map((filter) => filter.organizationId), @@ -259,6 +271,22 @@ export class VaultComponent implements OnInit, OnDestroy { shareReplay({ refCount: true, bufferSize: 1 }), ); + this.editableCollections$ = allCollectionsWithoutUnassigned$.pipe( + map((collections) => { + if ( + this.organization.canEditAnyCollection && + this.organization.allowAdminAccessToAllCollectionItems + ) { + return collections; + } + if (this.organization.isProviderUser) { + return collections; + } + return collections.filter((c) => c.assigned && !c.readOnly); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + const allCollections$ = combineLatest([organizationId$, allCollectionsWithoutUnassigned$]).pipe( map(([organizationId, allCollections]) => { const noneCollection = new CollectionAdminView(); @@ -277,32 +305,35 @@ export class VaultComponent implements OnInit, OnDestroy { const allCiphers$ = organization$.pipe( concatMap(async (organization) => { let ciphers; - if (organization.canEditAnyCollection) { - ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + + if (this.flexibleCollectionsV1Enabled) { + // Flexible collections V1 logic. + // If the user can edit all ciphers for the organization then fetch them ALL. + if (organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { + ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + } else { + // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). + ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); + } } else { - ciphers = (await this.cipherService.getAllDecrypted()).filter( - (c) => c.organizationId === organization.id, - ); + // Pre-flexible collections logic, to be removed after flexible collections is fully released + if (organization.canEditAnyCollection) { + ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + } else { + ciphers = (await this.cipherService.getAllDecrypted()).filter( + (c) => c.organizationId === organization.id, + ); + } } - await this.searchService.indexCiphers(ciphers, organization.id); + + this.searchService.indexCiphers(ciphers, organization.id); return ciphers; }), ); - const ciphers$ = combineLatest([allCiphers$, filter$, this.currentSearchText$]).pipe( - filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), - concatMap(async ([ciphers, filter, searchText]) => { - if (filter.collectionId === undefined && filter.type === undefined) { - return []; - } - - const filterFunction = createFilterFunction(filter); - - if (this.searchService.isSearchable(searchText)) { - return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); - } - - return ciphers.filter(filterFunction); + const allCipherMap$ = allCiphers$.pipe( + map((ciphers) => { + return Object.fromEntries(ciphers.map((c) => [c.id, c])); }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -364,6 +395,52 @@ export class VaultComponent implements OnInit, OnDestroy { shareReplay({ refCount: true, bufferSize: 1 }), ); + const showCollectionAccessRestricted$ = combineLatest([ + filter$, + selectedCollection$, + organization$, + ]).pipe( + map(([filter, collection, organization]) => { + return ( + (filter.collectionId === Unassigned && !organization.canUseAdminCollections) || + (!organization.allowAdminAccessToAllCollectionItems && + !organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && + collection != undefined && + !collection.node.assigned) + ); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const ciphers$ = combineLatest([ + allCiphers$, + filter$, + this.currentSearchText$, + showCollectionAccessRestricted$, + ]).pipe( + filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), + concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted]) => { + if (filter.collectionId === undefined && filter.type === undefined) { + return []; + } + + if (this.flexibleCollectionsV1Enabled && showCollectionAccessRestricted) { + // Do not show ciphers for restricted collections + // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible + return []; + } + + const filterFunction = createFilterFunction(filter); + + if (this.searchService.isSearchable(searchText)) { + return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); + } + + return ciphers.filter(filterFunction); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + const showMissingCollectionPermissionMessage$ = combineLatest([ filter$, selectedCollection$, @@ -390,23 +467,28 @@ export class VaultComponent implements OnInit, OnDestroy { if (!cipherId) { return; } - if ( - // Handle users with implicit collection access since they use the admin endpoint - organization.canUseAdminCollections || - (await this.cipherService.get(cipherId)) != null - ) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.editCipherId(cipherId); + + let canEditCipher: boolean; + + if (this.flexibleCollectionsV1Enabled) { + canEditCipher = + organization.canEditAllCiphers(true) || + (await firstValueFrom(allCipherMap$))[cipherId] != undefined; + } else { + canEditCipher = + organization.canUseAdminCollections || + (await this.cipherService.get(cipherId)) != null; + } + + if (canEditCipher) { + await this.editCipherId(cipherId); } else { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), this.i18nService.t("unknownCipher"), ); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([], { + await this.router.navigate([], { queryParams: { cipherId: null, itemId: null }, queryParamsHandling: "merge", }); @@ -461,6 +543,7 @@ export class VaultComponent implements OnInit, OnDestroy { collections$, selectedCollection$, showMissingCollectionPermissionMessage$, + showCollectionAccessRestricted$, ]), ), takeUntil(this.destroy$), @@ -475,6 +558,7 @@ export class VaultComponent implements OnInit, OnDestroy { collections, selectedCollection, showMissingCollectionPermissionMessage, + showCollectionAccessRestricted, ]) => { this.organization = organization; this.filter = filter; @@ -484,6 +568,8 @@ export class VaultComponent implements OnInit, OnDestroy { this.collections = collections; this.selectedCollection = selectedCollection; this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage; + this.showCollectionAccessRestricted = showCollectionAccessRestricted; + this.isEmpty = collections?.length === 0 && ciphers?.length === 0; // This is a temporary fix to avoid double fetching collections. @@ -591,13 +677,22 @@ export class VaultComponent implements OnInit, OnDestroy { } async editCipherCollections(cipher: CipherView) { - const currCollections = await firstValueFrom(this.vaultFilterService.filteredCollections$); + let collections: CollectionView[] = []; + + if (this.flexibleCollectionsV1Enabled) { + // V1 limits admins to only adding items to collections they have access to. + collections = await firstValueFrom(this.editableCollections$); + } else { + collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( + (c) => !c.readOnly && c.id != Unassigned, + ); + } const [modal] = await this.modalService.openViewRef( CollectionsComponent, this.collectionsModalRef, (comp) => { comp.collectionIds = cipher.collectionIds; - comp.collections = currCollections.filter((c) => !c.readOnly && c.id != Unassigned); + comp.collections = collections; comp.organization = this.organization; comp.cipherId = cipher.id; comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => { @@ -609,9 +704,16 @@ export class VaultComponent implements OnInit, OnDestroy { } async addCipher() { - const collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( - (c) => !c.readOnly && c.id != Unassigned, - ); + let collections: CollectionView[] = []; + + if (this.flexibleCollectionsV1Enabled) { + // V1 limits admins to only adding items to collections they have access to. + collections = await firstValueFrom(this.editableCollections$); + } else { + collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( + (c) => !c.readOnly && c.id != Unassigned, + ); + } await this.editCipher(null, (comp) => { comp.type = this.activeFilter.cipherType; @@ -701,9 +803,16 @@ export class VaultComponent implements OnInit, OnDestroy { } } - const collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( - (c) => !c.readOnly && c.id != Unassigned, - ); + let collections: CollectionView[] = []; + + if (this.flexibleCollectionsV1Enabled) { + // V1 limits admins to only adding items to collections they have access to. + collections = await firstValueFrom(this.editableCollections$); + } else { + collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( + (c) => !c.readOnly && c.id != Unassigned, + ); + } await this.editCipher(cipher, (comp) => { comp.cloneMode = true; @@ -1008,6 +1117,8 @@ export class VaultComponent implements OnInit, OnDestroy { replaceUrl: true, }); } + + protected readonly CollectionDialogTabType = CollectionDialogTabType; } /** diff --git a/apps/web/src/app/vault/org-vault/vault.module.ts b/apps/web/src/app/vault/org-vault/vault.module.ts index 7a9cb7b470..47365bb4b1 100644 --- a/apps/web/src/app/vault/org-vault/vault.module.ts +++ b/apps/web/src/app/vault/org-vault/vault.module.ts @@ -1,6 +1,6 @@ import { NgModule } from "@angular/core"; -import { BreadcrumbsModule } from "@bitwarden/components"; +import { BreadcrumbsModule, NoItemsModule } from "@bitwarden/components"; import { LooseComponentsModule } from "../../shared/loose-components.module"; import { SharedModule } from "../../shared/shared.module"; @@ -31,6 +31,7 @@ import { VaultComponent } from "./vault.component"; VaultItemsModule, CollectionDialogModule, CollectionAccessRestrictedComponent, + NoItemsModule, ], declarations: [VaultComponent, VaultHeaderComponent], exports: [VaultComponent], diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index e2b73a1ccb..5c0eadc739 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -1,6 +1,6 @@ import { DatePipe } from "@angular/common"; import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Observable, Subject, takeUntil, concatMap } from "rxjs"; +import { concatMap, Observable, Subject, takeUntil } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -12,6 +12,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; 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"; @@ -22,7 +24,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SecureNoteType, UriMatchType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherType, SecureNoteType, UriMatchType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; @@ -87,6 +89,8 @@ export class AddEditComponent implements OnInit, OnDestroy { private personalOwnershipPolicyAppliesToActiveUser: boolean; private previousCipherId: string; + protected flexibleCollectionsV1Enabled = false; + get fido2CredentialCreationDateValue(): string { const dateCreated = this.i18nService.t("dateCreated"); const creationDate = this.datePipe.transform( @@ -114,6 +118,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected dialogService: DialogService, protected win: Window, protected datePipe: DatePipe, + protected configService: ConfigServiceAbstraction, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -174,6 +179,10 @@ export class AddEditComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag( + FeatureFlag.FlexibleCollectionsV1, + false, + ); this.writeableCollections = await this.loadCollections(); this.canUseReprompt = await this.passwordRepromptService.enabled(); @@ -650,7 +659,13 @@ export class AddEditComponent implements OnInit, OnDestroy { protected saveCipher(cipher: Cipher) { const isNotClone = this.editMode && !this.cloneMode; - const orgAdmin = this.organization?.isAdmin; + let orgAdmin = this.organization?.isAdmin; + + if (this.flexibleCollectionsV1Enabled) { + // Flexible Collections V1 restricts admins, check the organization setting via canEditAllCiphers + orgAdmin = this.organization?.canEditAllCiphers(true); + } + return this.cipher.id == null ? this.cipherService.createWithServer(cipher, orgAdmin) : this.cipherService.updateWithServer(cipher, orgAdmin, isNotClone); diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 30dc81303e..16f445d673 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -196,6 +196,20 @@ export class Organization { return this.canEditAnyCollection; } + canEditAllCiphers(flexibleCollectionsV1Enabled: boolean) { + // Before Flexible Collections, anyone with editAnyCollection permission could edit all ciphers + if (!flexibleCollectionsV1Enabled) { + return this.canEditAnyCollection; + } + // Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins + // Providers are not affected by allowAdminAccessToAllCollectionItems flag + // note: canEditAnyCollection may change in the V1 to also ignore the allowAdminAccessToAllCollectionItems flag + return ( + this.isProviderUser || + (this.allowAdminAccessToAllCollectionItems && this.canEditAnyCollection) + ); + } + get canDeleteAnyCollection() { return this.isAdmin || this.permissions.deleteAnyCollection; } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 5ae88bd6b2..ecad46a411 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -27,6 +27,11 @@ export abstract class CipherService { defaultMatch?: UriMatchType, ) => Promise; getAllFromApiForOrganization: (organizationId: string) => Promise; + /** + * Gets ciphers belonging to the specified organization that the user has explicit collection level access to. + * Ciphers that are not assigned to any collections are only included for users with admin access. + */ + getManyFromApiForOrganization: (organizationId: string) => Promise; getLastUsedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise; getLastLaunchedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise; getNextCipherForUrl: (url: string) => Promise; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 3ac19ebc67..2792b41aff 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; import { SettingsService } from "../../abstractions/settings.service"; import { ErrorResponse } from "../../models/response/error.response"; +import { ListResponse } from "../../models/response/list.response"; import { View } from "../../models/view/view"; import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "../../platform/abstractions/crypto.service"; @@ -387,6 +388,24 @@ export class CipherService implements CipherServiceAbstraction { async getAllFromApiForOrganization(organizationId: string): Promise { const response = await this.apiService.getCiphersOrganization(organizationId); + return await this.decryptOrganizationCiphersResponse(response, organizationId); + } + + async getManyFromApiForOrganization(organizationId: string): Promise { + const response = await this.apiService.send( + "GET", + "/ciphers/organization-details/assigned?organizationId=" + organizationId, + null, + true, + true, + ); + return this.decryptOrganizationCiphersResponse(response, organizationId); + } + + private async decryptOrganizationCiphersResponse( + response: ListResponse, + organizationId: string, + ): Promise { if (response?.data == null || response.data.length < 1) { return []; }