[AC-1124] Restrict admins from accessing items in the Collections tab (#7537)

* [AC-1124] Add getManyFromApiForOrganization to cipher.service.ts

* [AC-1124] Use getManyFromApiForOrganization when a user does not have access to all ciphers

* [AC-1124] Vault changes
- Show new collection access restricted view
- Include unassigned ciphers for restricted admins
- Restrict collections when creating/cloning/editing ciphers

* [AC-1124] Update edit cipher on page navigation to check if user can access the cipher

* [AC-1124] Hide ciphers from restricted collections

* [AC-1124] Ensure providers are not shown collection access restricted view

* [AC-1124] Modify add-edit component to call the correct endpoint when a restricted admin attempts to add-edit a cipher

* [AC-1124] Fix bug after merge with main

* [AC-1124] Use private this._organization

* [AC-1124] Fix broken builds
This commit is contained in:
Shane Melton 2024-02-08 14:07:42 -08:00 committed by GitHub
parent 3ee27fc61f
commit 5c6245aaae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 284 additions and 76 deletions

View File

@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service";
import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service"; import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service";
import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data"; 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({ @Component({
selector: "app-vault-add-edit", selector: "app-vault-add-edit",
@ -66,6 +67,7 @@ export class AddEditComponent extends BaseAddEditComponent {
sendApiService: SendApiService, sendApiService: SendApiService,
dialogService: DialogService, dialogService: DialogService,
datePipe: DatePipe, datePipe: DatePipe,
configService: ConfigServiceAbstraction,
) { ) {
super( super(
cipherService, cipherService,
@ -85,6 +87,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
window, window,
datePipe, datePipe,
configService,
); );
} }

View File

@ -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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@ -48,6 +49,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnChanges,
sendApiService: SendApiService, sendApiService: SendApiService,
dialogService: DialogService, dialogService: DialogService,
datePipe: DatePipe, datePipe: DatePipe,
configService: ConfigServiceAbstraction,
) { ) {
super( super(
cipherService, cipherService,
@ -67,6 +69,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnChanges,
dialogService, dialogService,
window, window,
datePipe, datePipe,
configService,
); );
} }

View File

@ -5,6 +5,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@ -50,6 +51,7 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent {
sendApiService: SendApiService, sendApiService: SendApiService,
dialogService: DialogService, dialogService: DialogService,
datePipe: DatePipe, datePipe: DatePipe,
configService: ConfigServiceAbstraction,
) { ) {
super( super(
cipherService, cipherService,
@ -70,6 +72,7 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent {
sendApiService, sendApiService,
dialogService, dialogService,
datePipe, datePipe,
configService,
); );
} }

View File

@ -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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { EventType, ProductType } from "@bitwarden/common/enums"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@ -62,6 +63,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
sendApiService: SendApiService, sendApiService: SendApiService,
dialogService: DialogService, dialogService: DialogService,
datePipe: DatePipe, datePipe: DatePipe,
configService: ConfigServiceAbstraction,
) { ) {
super( super(
cipherService, cipherService,
@ -81,6 +83,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
dialogService, dialogService,
window, window,
datePipe, datePipe,
configService,
); );
} }

View File

@ -6,6 +6,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@ -52,6 +53,7 @@ export class AddEditComponent extends BaseAddEditComponent {
sendApiService: SendApiService, sendApiService: SendApiService,
dialogService: DialogService, dialogService: DialogService,
datePipe: DatePipe, datePipe: DatePipe,
configService: ConfigServiceAbstraction,
) { ) {
super( super(
cipherService, cipherService,
@ -72,6 +74,7 @@ export class AddEditComponent extends BaseAddEditComponent {
sendApiService, sendApiService,
dialogService, dialogService,
datePipe, datePipe,
configService,
); );
} }
@ -81,7 +84,9 @@ export class AddEditComponent extends BaseAddEditComponent {
(this.ownershipOptions.length > 1 || !this.allowPersonal) (this.ownershipOptions.length > 1 || !this.allowPersonal)
) { ) {
if (this.organization != null) { if (this.organization != null) {
return this.cloneMode && this.organization.canEditAnyCollection; return (
this.cloneMode && this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)
);
} else { } else {
return !this.editMode || this.cloneMode; return !this.editMode || this.cloneMode;
} }
@ -90,14 +95,14 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
protected loadCollections() { protected loadCollections() {
if (!this.organization.canEditAnyCollection) { if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
return super.loadCollections(); return super.loadCollections();
} }
return Promise.resolve(this.collections); return Promise.resolve(this.collections);
} }
protected async loadCipher() { protected async loadCipher() {
if (!this.organization.canEditAnyCollection) { if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
return await super.loadCipher(); return await super.loadCipher();
} }
const response = await this.apiService.getCipherAdmin(this.cipherId); const response = await this.apiService.getCipherAdmin(this.cipherId);
@ -110,14 +115,14 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
protected encryptCipher() { protected encryptCipher() {
if (!this.organization.canEditAnyCollection) { if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
return super.encryptCipher(); return super.encryptCipher();
} }
return this.cipherService.encrypt(this.cipher, null, null, this.originalCipher); return this.cipherService.encrypt(this.cipher, null, null, this.originalCipher);
} }
protected async deleteCipher() { protected async deleteCipher() {
if (!this.organization.canEditAnyCollection) { if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
return super.deleteCipher(); return super.deleteCipher();
} }
return this.cipher.isDeleted return this.cipher.isDeleted

View File

@ -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"; import { ButtonModule, NoItemsModule, svgIcon } from "@bitwarden/components";
@ -13,9 +13,10 @@ const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="140" height=
selector: "collection-access-restricted", selector: "collection-access-restricted",
standalone: true, standalone: true,
imports: [SharedModule, ButtonModule, NoItemsModule], imports: [SharedModule, ButtonModule, NoItemsModule],
template: `<bit-no-items [icon]="icon"> template: `<bit-no-items [icon]="icon" class="tw-mt-2 tw-block">
<span slot="title" class="tw-mt-4 tw-block">{{ "collectionAccessRestricted" | i18n }}</span> <span slot="title" class="tw-mt-4 tw-block">{{ "collectionAccessRestricted" | i18n }}</span>
<button <button
*ngIf="canEdit"
slot="button" slot="button"
bitButton bitButton
(click)="editInfoClicked.emit()" (click)="editInfoClicked.emit()"
@ -29,5 +30,7 @@ const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="140" height=
export class CollectionAccessRestrictedComponent { export class CollectionAccessRestrictedComponent {
protected icon = icon; protected icon = icon;
@Input() canEdit = false;
@Output() editInfoClicked = new EventEmitter<void>(); @Output() editInfoClicked = new EventEmitter<void>();
} }

View File

@ -11,8 +11,8 @@ import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individu
import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { import {
VaultFilterList, VaultFilterList,
VaultFilterType,
VaultFilterSection, VaultFilterSection,
VaultFilterType,
} from "../../individual-vault/vault-filter/shared/models/vault-filter-section.type"; } from "../../individual-vault/vault-filter/shared/models/vault-filter-section.type";
import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type"; import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type";

View File

@ -61,30 +61,53 @@
[viewingOrgVault]="true" [viewingOrgVault]="true"
> >
</app-vault-items> </app-vault-items>
<div <ng-container *ngIf="!flexibleCollectionsV1Enabled">
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start" <div
*ngIf="showMissingCollectionPermissionMessage" class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
> *ngIf="showMissingCollectionPermissionMessage"
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noPermissionToViewAllCollectionItems" | i18n }}</p>
</div>
<div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="isEmpty && !showMissingCollectionPermissionMessage && !performingInitialLoad"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned"
> >
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> <bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
{{ "newItem" | i18n }} <p>{{ "noPermissionToViewAllCollectionItems" | i18n }}</p>
</button> </div>
</div> <div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="isEmpty && !showMissingCollectionPermissionMessage && !performingInitialLoad"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}
</button>
</div>
</ng-container>
<ng-container *ngIf="flexibleCollectionsV1Enabled && !performingInitialLoad">
<bit-no-items *ngIf="isEmpty && !showCollectionAccessRestricted">
<span slot="title" class="tw-mt-4 tw-block">{{ "noItemsInList" | i18n }}</span>
<button
slot="button"
bitButton
(click)="addCipher()"
buttonType="primary"
type="button"
*ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned"
>
<i aria-hidden="true" class="bwi bwi-plus"></i> {{ "newItem" | i18n }}
</button>
</bit-no-items>
<collection-access-restricted
*ngIf="showCollectionAccessRestricted"
[canEdit]="selectedCollection != null && selectedCollection.node.canEdit(organization)"
(editInfoClicked)="editCollection(selectedCollection.node, CollectionDialogTabType.Info)"
>
</collection-access-restricted>
</ng-container>
<div <div
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start" class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
*ngIf="performingInitialLoad" *ngIf="performingInitialLoad"

View File

@ -127,13 +127,20 @@ export class VaultComponent implements OnInit, OnDestroy {
protected collections: CollectionAdminView[]; protected collections: CollectionAdminView[];
protected selectedCollection: TreeNode<CollectionAdminView> | undefined; protected selectedCollection: TreeNode<CollectionAdminView> | undefined;
protected isEmpty: boolean; 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 showMissingCollectionPermissionMessage: boolean;
protected showCollectionAccessRestricted: boolean;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
protected editableCollections$: Observable<CollectionView[]>;
protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$( protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$(
FeatureFlag.BulkCollectionAccess, FeatureFlag.BulkCollectionAccess,
false, false,
); );
protected flexibleCollectionsEnabled: boolean; protected flexibleCollectionsV1Enabled: boolean;
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);
@ -176,6 +183,11 @@ export class VaultComponent implements OnInit, OnDestroy {
: "trashCleanupWarning", : "trashCleanupWarning",
); );
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1,
false,
);
const filter$ = this.routedVaultFilterService.filter$; const filter$ = this.routedVaultFilterService.filter$;
const organizationId$ = filter$.pipe( const organizationId$ = filter$.pipe(
map((filter) => filter.organizationId), map((filter) => filter.organizationId),
@ -259,6 +271,22 @@ export class VaultComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }), 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( const allCollections$ = combineLatest([organizationId$, allCollectionsWithoutUnassigned$]).pipe(
map(([organizationId, allCollections]) => { map(([organizationId, allCollections]) => {
const noneCollection = new CollectionAdminView(); const noneCollection = new CollectionAdminView();
@ -277,32 +305,35 @@ export class VaultComponent implements OnInit, OnDestroy {
const allCiphers$ = organization$.pipe( const allCiphers$ = organization$.pipe(
concatMap(async (organization) => { concatMap(async (organization) => {
let ciphers; 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 { } else {
ciphers = (await this.cipherService.getAllDecrypted()).filter( // Pre-flexible collections logic, to be removed after flexible collections is fully released
(c) => c.organizationId === organization.id, 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; return ciphers;
}), }),
); );
const ciphers$ = combineLatest([allCiphers$, filter$, this.currentSearchText$]).pipe( const allCipherMap$ = allCiphers$.pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), map((ciphers) => {
concatMap(async ([ciphers, filter, searchText]) => { return Object.fromEntries(ciphers.map((c) => [c.id, c]));
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);
}), }),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
@ -364,6 +395,52 @@ export class VaultComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }), 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([ const showMissingCollectionPermissionMessage$ = combineLatest([
filter$, filter$,
selectedCollection$, selectedCollection$,
@ -390,23 +467,28 @@ export class VaultComponent implements OnInit, OnDestroy {
if (!cipherId) { if (!cipherId) {
return; return;
} }
if (
// Handle users with implicit collection access since they use the admin endpoint let canEditCipher: boolean;
organization.canUseAdminCollections ||
(await this.cipherService.get(cipherId)) != null if (this.flexibleCollectionsV1Enabled) {
) { canEditCipher =
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. organization.canEditAllCiphers(true) ||
// eslint-disable-next-line @typescript-eslint/no-floating-promises (await firstValueFrom(allCipherMap$))[cipherId] != undefined;
this.editCipherId(cipherId); } else {
canEditCipher =
organization.canUseAdminCollections ||
(await this.cipherService.get(cipherId)) != null;
}
if (canEditCipher) {
await this.editCipherId(cipherId);
} else { } else {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"error", "error",
this.i18nService.t("errorOccurred"), this.i18nService.t("errorOccurred"),
this.i18nService.t("unknownCipher"), 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. await this.router.navigate([], {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([], {
queryParams: { cipherId: null, itemId: null }, queryParams: { cipherId: null, itemId: null },
queryParamsHandling: "merge", queryParamsHandling: "merge",
}); });
@ -461,6 +543,7 @@ export class VaultComponent implements OnInit, OnDestroy {
collections$, collections$,
selectedCollection$, selectedCollection$,
showMissingCollectionPermissionMessage$, showMissingCollectionPermissionMessage$,
showCollectionAccessRestricted$,
]), ]),
), ),
takeUntil(this.destroy$), takeUntil(this.destroy$),
@ -475,6 +558,7 @@ export class VaultComponent implements OnInit, OnDestroy {
collections, collections,
selectedCollection, selectedCollection,
showMissingCollectionPermissionMessage, showMissingCollectionPermissionMessage,
showCollectionAccessRestricted,
]) => { ]) => {
this.organization = organization; this.organization = organization;
this.filter = filter; this.filter = filter;
@ -484,6 +568,8 @@ export class VaultComponent implements OnInit, OnDestroy {
this.collections = collections; this.collections = collections;
this.selectedCollection = selectedCollection; this.selectedCollection = selectedCollection;
this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage; this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage;
this.showCollectionAccessRestricted = showCollectionAccessRestricted;
this.isEmpty = collections?.length === 0 && ciphers?.length === 0; this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
// This is a temporary fix to avoid double fetching collections. // This is a temporary fix to avoid double fetching collections.
@ -591,13 +677,22 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
async editCipherCollections(cipher: CipherView) { 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( const [modal] = await this.modalService.openViewRef(
CollectionsComponent, CollectionsComponent,
this.collectionsModalRef, this.collectionsModalRef,
(comp) => { (comp) => {
comp.collectionIds = cipher.collectionIds; comp.collectionIds = cipher.collectionIds;
comp.collections = currCollections.filter((c) => !c.readOnly && c.id != Unassigned); comp.collections = collections;
comp.organization = this.organization; comp.organization = this.organization;
comp.cipherId = cipher.id; comp.cipherId = cipher.id;
comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => { comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => {
@ -609,9 +704,16 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
async addCipher() { async addCipher() {
const collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( let collections: CollectionView[] = [];
(c) => !c.readOnly && c.id != Unassigned,
); 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) => { await this.editCipher(null, (comp) => {
comp.type = this.activeFilter.cipherType; comp.type = this.activeFilter.cipherType;
@ -701,9 +803,16 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
const collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( let collections: CollectionView[] = [];
(c) => !c.readOnly && c.id != Unassigned,
); 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) => { await this.editCipher(cipher, (comp) => {
comp.cloneMode = true; comp.cloneMode = true;
@ -1008,6 +1117,8 @@ export class VaultComponent implements OnInit, OnDestroy {
replaceUrl: true, replaceUrl: true,
}); });
} }
protected readonly CollectionDialogTabType = CollectionDialogTabType;
} }
/** /**

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BreadcrumbsModule } from "@bitwarden/components"; import { BreadcrumbsModule, NoItemsModule } from "@bitwarden/components";
import { LooseComponentsModule } from "../../shared/loose-components.module"; import { LooseComponentsModule } from "../../shared/loose-components.module";
import { SharedModule } from "../../shared/shared.module"; import { SharedModule } from "../../shared/shared.module";
@ -31,6 +31,7 @@ import { VaultComponent } from "./vault.component";
VaultItemsModule, VaultItemsModule,
CollectionDialogModule, CollectionDialogModule,
CollectionAccessRestrictedComponent, CollectionAccessRestrictedComponent,
NoItemsModule,
], ],
declarations: [VaultComponent, VaultHeaderComponent], declarations: [VaultComponent, VaultHeaderComponent],
exports: [VaultComponent], exports: [VaultComponent],

View File

@ -1,6 +1,6 @@
import { DatePipe } from "@angular/common"; import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; 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 { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.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 { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventType } from "@bitwarden/common/enums"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; 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 { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CardView } from "@bitwarden/common/vault/models/view/card.view";
@ -87,6 +89,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
private personalOwnershipPolicyAppliesToActiveUser: boolean; private personalOwnershipPolicyAppliesToActiveUser: boolean;
private previousCipherId: string; private previousCipherId: string;
protected flexibleCollectionsV1Enabled = false;
get fido2CredentialCreationDateValue(): string { get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated"); const dateCreated = this.i18nService.t("dateCreated");
const creationDate = this.datePipe.transform( const creationDate = this.datePipe.transform(
@ -114,6 +118,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected dialogService: DialogService, protected dialogService: DialogService,
protected win: Window, protected win: Window,
protected datePipe: DatePipe, protected datePipe: DatePipe,
protected configService: ConfigServiceAbstraction,
) { ) {
this.typeOptions = [ this.typeOptions = [
{ name: i18nService.t("typeLogin"), value: CipherType.Login }, { name: i18nService.t("typeLogin"), value: CipherType.Login },
@ -174,6 +179,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
} }
async ngOnInit() { async ngOnInit() {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1,
false,
);
this.writeableCollections = await this.loadCollections(); this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled(); this.canUseReprompt = await this.passwordRepromptService.enabled();
@ -650,7 +659,13 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected saveCipher(cipher: Cipher) { protected saveCipher(cipher: Cipher) {
const isNotClone = this.editMode && !this.cloneMode; 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 return this.cipher.id == null
? this.cipherService.createWithServer(cipher, orgAdmin) ? this.cipherService.createWithServer(cipher, orgAdmin)
: this.cipherService.updateWithServer(cipher, orgAdmin, isNotClone); : this.cipherService.updateWithServer(cipher, orgAdmin, isNotClone);

View File

@ -196,6 +196,20 @@ export class Organization {
return this.canEditAnyCollection; 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() { get canDeleteAnyCollection() {
return this.isAdmin || this.permissions.deleteAnyCollection; return this.isAdmin || this.permissions.deleteAnyCollection;
} }

View File

@ -27,6 +27,11 @@ export abstract class CipherService {
defaultMatch?: UriMatchType, defaultMatch?: UriMatchType,
) => Promise<CipherView[]>; ) => Promise<CipherView[]>;
getAllFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>; getAllFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>;
/**
* 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<CipherView[]>;
getLastUsedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>; getLastUsedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>;
getLastLaunchedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>; getLastLaunchedForUrl: (url: string, autofillOnPageLoad: boolean) => Promise<CipherView>;
getNextCipherForUrl: (url: string) => Promise<CipherView>; getNextCipherForUrl: (url: string) => Promise<CipherView>;

View File

@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
import { SettingsService } from "../../abstractions/settings.service"; import { SettingsService } from "../../abstractions/settings.service";
import { ErrorResponse } from "../../models/response/error.response"; import { ErrorResponse } from "../../models/response/error.response";
import { ListResponse } from "../../models/response/list.response";
import { View } from "../../models/view/view"; import { View } from "../../models/view/view";
import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction";
import { CryptoService } from "../../platform/abstractions/crypto.service"; import { CryptoService } from "../../platform/abstractions/crypto.service";
@ -387,6 +388,24 @@ export class CipherService implements CipherServiceAbstraction {
async getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]> { async getAllFromApiForOrganization(organizationId: string): Promise<CipherView[]> {
const response = await this.apiService.getCiphersOrganization(organizationId); const response = await this.apiService.getCiphersOrganization(organizationId);
return await this.decryptOrganizationCiphersResponse(response, organizationId);
}
async getManyFromApiForOrganization(organizationId: string): Promise<CipherView[]> {
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<CipherResponse>,
organizationId: string,
): Promise<CipherView[]> {
if (response?.data == null || response.data.length < 1) { if (response?.data == null || response.data.length < 1) {
return []; return [];
} }