[AC-1707] Restrict provider access to items (#8265)

* [AC-1707] Add feature flag

* [AC-1707] Prevent loading ciphers for provider users in the org vault when the feature flag is enabled

* [AC-1707] Ensure new canEditAllCiphers logic only applies to organizations that have FC enabled

* [AC-1707] Update editAllCiphers helper to check for restrictProviderAccess feature flag

* [AC-1707] Remove un-used vaultFilterComponent reference

* [AC-1707] Hide vault filter for providers

* [AC-1707] Add search to vault header for provider users

* [AC-1707] Hide New Item button for Providers when restrict provider access feature flag is enabled

* [AC-1707] Remove leftover debug statement

* [AC-1707] Update canEditAllCiphers references to consider the restrictProviderAccessFlag

* [AC-1707] Fix collections component changes from main

* [AC-1707] Fix some feature flag issues from merge with main

* [AC-1707] Avoid 'readonly' collection dialog for providers

* [AC-1707] Fix broken Browser component

* [AC-1707] Fix broken Desktop component

* [AC-1707] Add restrict provider flag to add access badge logic
This commit is contained in:
Shane Melton 2024-05-07 12:35:28 -07:00 committed by GitHub
parent 27d4178287
commit 3a71322510
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 273 additions and 57 deletions

View File

@ -1,4 +1,4 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core"; import { Component, Inject } from "@angular/core";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@ -56,6 +56,10 @@ export class BulkDeleteDialogComponent {
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
private restrictProviderAccess$ = this.configService.getFeatureFlag$(
FeatureFlag.RestrictProviderAccess,
);
constructor( constructor(
@Inject(DIALOG_DATA) params: BulkDeleteDialogParams, @Inject(DIALOG_DATA) params: BulkDeleteDialogParams,
private dialogRef: DialogRef<BulkDeleteDialogResult>, private dialogRef: DialogRef<BulkDeleteDialogResult>,
@ -81,10 +85,11 @@ export class BulkDeleteDialogComponent {
const deletePromises: Promise<void>[] = []; const deletePromises: Promise<void>[] = [];
if (this.cipherIds.length) { if (this.cipherIds.length) {
const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$); const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$);
const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$);
if ( if (
!this.organization || !this.organization ||
!this.organization.canEditAllCiphers(flexibleCollectionsV1Enabled) !this.organization.canEditAllCiphers(flexibleCollectionsV1Enabled, restrictProviderAccess)
) { ) {
deletePromises.push(this.deleteCiphers()); deletePromises.push(this.deleteCiphers());
} else { } else {
@ -118,7 +123,11 @@ export class BulkDeleteDialogComponent {
private async deleteCiphers(): Promise<any> { private async deleteCiphers(): Promise<any> {
const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$); const flexibleCollectionsV1Enabled = await firstValueFrom(this.flexibleCollectionsV1Enabled$);
const asAdmin = this.organization?.canEditAllCiphers(flexibleCollectionsV1Enabled); const restrictProviderAccess = await firstValueFrom(this.restrictProviderAccess$);
const asAdmin = this.organization?.canEditAllCiphers(
flexibleCollectionsV1Enabled,
restrictProviderAccess,
);
if (this.permanent) { if (this.permanent) {
await this.cipherService.deleteManyWithServer(this.cipherIds, asAdmin); await this.cipherService.deleteManyWithServer(this.cipherIds, asAdmin);
} else { } else {

View File

@ -32,7 +32,13 @@
[(ngModel)]="$any(c).checked" [(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked" name="Collection[{{ i }}].Checked"
appStopProp appStopProp
[disabled]="!c.canEditItems(this.organization, this.flexibleCollectionsV1Enabled)" [disabled]="
!c.canEditItems(
this.organization,
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess
)
"
/> />
{{ c.name }} {{ c.name }}
</td> </td>

View File

@ -50,7 +50,13 @@ export class CollectionsComponent extends BaseCollectionsComponent implements On
} }
check(c: CollectionView, select?: boolean) { check(c: CollectionView, select?: boolean) {
if (!c.canEditItems(this.organization, this.flexibleCollectionsV1Enabled)) { if (
!c.canEditItems(
this.organization,
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return; return;
} }
(c as any).checked = select == null ? !(c as any).checked : select; (c as any).checked = select == null ? !(c as any).checked : select;

View File

@ -82,7 +82,12 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
protected loadCollections() { protected loadCollections() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.loadCollections(); return super.loadCollections();
} }
return Promise.resolve(this.collections); return Promise.resolve(this.collections);
@ -93,7 +98,10 @@ export class AddEditComponent extends BaseAddEditComponent {
const firstCipherCheck = await super.loadCipher(); const firstCipherCheck = await super.loadCipher();
if ( if (
!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && !this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) &&
firstCipherCheck != null firstCipherCheck != null
) { ) {
return firstCipherCheck; return firstCipherCheck;
@ -108,14 +116,24 @@ export class AddEditComponent extends BaseAddEditComponent {
} }
protected encryptCipher() { protected encryptCipher() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
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.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.deleteCipher(); return super.deleteCipher();
} }
return this.cipher.isDeleted return this.cipher.isDeleted

View File

@ -29,6 +29,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
organization: Organization; organization: Organization;
private flexibleCollectionsV1Enabled = false; private flexibleCollectionsV1Enabled = false;
private restrictProviderAccess = false;
constructor( constructor(
cipherService: CipherService, cipherService: CipherService,
@ -62,11 +63,17 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
this.flexibleCollectionsV1Enabled = await firstValueFrom( this.flexibleCollectionsV1Enabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1), this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
); );
this.restrictProviderAccess = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.RestrictProviderAccess),
);
} }
protected async reupload(attachment: AttachmentView) { protected async reupload(attachment: AttachmentView) {
if ( if (
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) &&
this.showFixOldAttachments(attachment) this.showFixOldAttachments(attachment)
) { ) {
await super.reuploadCipherAttachment(attachment, true); await super.reuploadCipherAttachment(attachment, true);
@ -74,7 +81,12 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
} }
protected async loadCipher() { protected async loadCipher() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return await super.loadCipher(); return await super.loadCipher();
} }
const response = await this.apiService.getCipherAdmin(this.cipherId); const response = await this.apiService.getCipherAdmin(this.cipherId);
@ -85,12 +97,20 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
return this.cipherService.saveAttachmentWithServer( return this.cipherService.saveAttachmentWithServer(
this.cipherDomain, this.cipherDomain,
file, file,
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled), this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
),
); );
} }
protected deleteCipherAttachment(attachmentId: string) { protected deleteCipherAttachment(attachmentId: string) {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
!this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return super.deleteCipherAttachment(attachmentId); return super.deleteCipherAttachment(attachmentId);
} }
return this.apiService.deleteCipherAttachmentAdmin(this.cipherId, attachmentId); return this.apiService.deleteCipherAttachmentAdmin(this.cipherId, attachmentId);
@ -99,7 +119,10 @@ export class AttachmentsComponent extends BaseAttachmentsComponent implements On
protected showFixOldAttachments(attachment: AttachmentView) { protected showFixOldAttachments(attachment: AttachmentView) {
return ( return (
attachment.key == null && attachment.key == null &&
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
); );
} }
} }

View File

@ -71,9 +71,12 @@ export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnIni
async ngOnInit() { async ngOnInit() {
const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1); const v1FCEnabled = await this.configService.getFeatureFlag(FeatureFlag.FlexibleCollectionsV1);
const restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
const org = await this.organizationService.get(this.params.organizationId); const org = await this.organizationService.get(this.params.organizationId);
if (org.canEditAllCiphers(v1FCEnabled)) { if (org.canEditAllCiphers(v1FCEnabled, restrictProviderAccess)) {
this.editableItems = this.params.ciphers; this.editableItems = this.params.ciphers;
} else { } else {
this.editableItems = this.params.ciphers.filter((c) => c.edit); this.editableItems = this.params.ciphers.filter((c) => c.edit);

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";
@ -22,12 +22,18 @@ const icon = svgIcon`<svg xmlns="http://www.w3.org/2000/svg" width="120" height=
buttonType="secondary" buttonType="secondary"
type="button" type="button"
> >
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "viewCollection" | i18n }} <i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ buttonText | i18n }}
</button> </button>
</bit-no-items>`, </bit-no-items>`,
}) })
export class CollectionAccessRestrictedComponent { export class CollectionAccessRestrictedComponent {
protected icon = icon; protected icon = icon;
@Input() canEditCollection = false;
@Output() viewCollectionClicked = new EventEmitter<void>(); @Output() viewCollectionClicked = new EventEmitter<void>();
get buttonText() {
return this.canEditCollection ? "editCollection" : "viewCollection";
}
} }

View File

@ -61,7 +61,10 @@ export class CollectionsComponent extends BaseCollectionsComponent {
protected async loadCipher() { protected async loadCipher() {
// if cipher is unassigned use apiService. We can see this by looking at this.collectionIds // if cipher is unassigned use apiService. We can see this by looking at this.collectionIds
if ( if (
!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && !this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) &&
this.collectionIds.length !== 0 this.collectionIds.length !== 0
) { ) {
return await super.loadCipher(); return await super.loadCipher();
@ -86,7 +89,10 @@ export class CollectionsComponent extends BaseCollectionsComponent {
protected saveCollections() { protected saveCollections() {
if ( if (
this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) || this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
) ||
this.collectionIds.length === 0 this.collectionIds.length === 0
) { ) {
const request = new CipherCollectionsRequest(this.cipherDomain.collectionIds); const request = new CipherCollectionsRequest(this.cipherDomain.collectionIds);

View File

@ -73,8 +73,16 @@
</small> </small>
</ng-container> </ng-container>
<bit-search
*ngIf="organization?.isProviderUser"
class="tw-grow"
[ngModel]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
[placeholder]="'searchCollection' | i18n"
></bit-search>
<div *ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned" class="tw-shrink-0"> <div *ngIf="filter.type !== 'trash' && filter.collectionId !== Unassigned" class="tw-shrink-0">
<div *ngIf="organization?.canCreateNewCollections" appListDropdown> <div *ngIf="canCreateCipher && canCreateCollection" appListDropdown>
<button <button
bitButton bitButton
buttonType="primary" buttonType="primary"
@ -97,7 +105,7 @@
</bit-menu> </bit-menu>
</div> </div>
<button <button
*ngIf="!organization?.canCreateNewCollections" *ngIf="canCreateCipher && !canCreateCollection"
type="button" type="button"
bitButton bitButton
buttonType="primary" buttonType="primary"
@ -106,5 +114,16 @@
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i> <i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }} {{ "newItem" | i18n }}
</button> </button>
<button
*ngIf="canCreateCollection && !canCreateCipher"
type="button"
bitButton
buttonType="primary"
(click)="addCollection()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newCollection" | i18n }}
</button>
</div> </div>
</app-header> </app-header>

View File

@ -43,6 +43,9 @@ export class VaultHeaderComponent implements OnInit {
/** Currently selected collection */ /** Currently selected collection */
@Input() collection?: TreeNode<CollectionAdminView>; @Input() collection?: TreeNode<CollectionAdminView>;
/** The current search text in the header */
@Input() searchText: string;
/** Emits an event when the new item button is clicked in the header */ /** Emits an event when the new item button is clicked in the header */
@Output() onAddCipher = new EventEmitter<void>(); @Output() onAddCipher = new EventEmitter<void>();
@ -55,10 +58,14 @@ export class VaultHeaderComponent implements OnInit {
/** Emits an event when the delete collection button is clicked in the header */ /** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>(); @Output() onDeleteCollection = new EventEmitter<void>();
/** Emits an event when the search text changes in the header*/
@Output() searchTextChanged = new EventEmitter<string>();
protected CollectionDialogTabType = CollectionDialogTabType; protected CollectionDialogTabType = CollectionDialogTabType;
protected organizations$ = this.organizationService.organizations$; protected organizations$ = this.organizationService.organizations$;
private flexibleCollectionsV1Enabled = false; private flexibleCollectionsV1Enabled = false;
private restrictProviderAccessFlag = false;
constructor( constructor(
private organizationService: OrganizationService, private organizationService: OrganizationService,
@ -73,6 +80,9 @@ export class VaultHeaderComponent implements OnInit {
this.flexibleCollectionsV1Enabled = await firstValueFrom( this.flexibleCollectionsV1Enabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1), this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
); );
this.restrictProviderAccessFlag = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
} }
get title() { get title() {
@ -197,7 +207,23 @@ export class VaultHeaderComponent implements OnInit {
return this.collection.node.canDelete(this.organization); return this.collection.node.canDelete(this.organization);
} }
get canCreateCollection(): boolean {
return this.organization?.canCreateNewCollections;
}
get canCreateCipher(): boolean {
if (this.organization?.isProviderUser && this.restrictProviderAccessFlag) {
return false;
}
return true;
}
deleteCollection() { deleteCollection() {
this.onDeleteCollection.emit(); this.onDeleteCollection.emit();
} }
onSearchTextChanged(t: string) {
this.searchText = t;
this.searchTextChanged.emit(t);
}
} }

View File

@ -3,19 +3,20 @@
[loading]="refreshing" [loading]="refreshing"
[organization]="organization" [organization]="organization"
[collection]="selectedCollection" [collection]="selectedCollection"
[searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher()" (onAddCipher)="addCipher()"
(onAddCollection)="addCollection()" (onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)" (onEditCollection)="editCollection(selectedCollection.node, $event.tab)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)" (onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)"
></app-org-vault-header> ></app-org-vault-header>
<div class="row"> <div class="row">
<div class="col-3"> <div class="col-3" *ngIf="!organization?.isProviderUser">
<div class="groupings"> <div class="groupings">
<div class="content"> <div class="content">
<div class="inner-content"> <div class="inner-content">
<app-organization-vault-filter <app-organization-vault-filter
#vaultFilter
[organization]="organization" [organization]="organization"
[activeFilter]="activeFilter" [activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async" [searchText]="currentSearchText$ | async"
@ -25,7 +26,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-9"> <div [class]="organization?.isProviderUser ? 'col-12' : 'col-9'">
<bit-toggle-group <bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode" *ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async" [selected]="addAccessStatus$ | async"
@ -114,8 +115,13 @@
</bit-no-items> </bit-no-items>
<collection-access-restricted <collection-access-restricted
*ngIf="showCollectionAccessRestricted" *ngIf="showCollectionAccessRestricted"
[canEditCollection]="organization.isProviderUser"
(viewCollectionClicked)=" (viewCollectionClicked)="
editCollection(selectedCollection.node, CollectionDialogTabType.Info, true) editCollection(
selectedCollection.node,
CollectionDialogTabType.Info,
!organization.isProviderUser
)
" "
> >
</collection-access-restricted> </collection-access-restricted>

View File

@ -100,7 +100,6 @@ import {
BulkCollectionsDialogResult, BulkCollectionsDialogResult,
} from "./bulk-collections-dialog"; } from "./bulk-collections-dialog";
import { openOrgVaultCollectionsDialog } from "./collections.component"; import { openOrgVaultCollectionsDialog } from "./collections.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
const BroadcasterSubscriptionId = "OrgVaultComponent"; const BroadcasterSubscriptionId = "OrgVaultComponent";
const SearchTextDebounceInterval = 200; const SearchTextDebounceInterval = 200;
@ -118,8 +117,6 @@ enum AddAccessStatusType {
export class VaultComponent implements OnInit, OnDestroy { export class VaultComponent implements OnInit, OnDestroy {
protected Unassigned = Unassigned; protected Unassigned = Unassigned;
@ViewChild("vaultFilter", { static: true })
vaultFilterComponent: VaultFilterComponent;
@ViewChild("attachments", { read: ViewContainerRef, static: true }) @ViewChild("attachments", { read: ViewContainerRef, static: true })
attachmentsModalRef: ViewContainerRef; attachmentsModalRef: ViewContainerRef;
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true }) @ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
@ -151,6 +148,10 @@ export class VaultComponent implements OnInit, OnDestroy {
protected showMissingCollectionPermissionMessage: boolean; protected showMissingCollectionPermissionMessage: boolean;
protected showCollectionAccessRestricted: boolean; protected showCollectionAccessRestricted: boolean;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
*/
protected editableCollections$: Observable<CollectionView[]>; protected editableCollections$: Observable<CollectionView[]>;
protected allCollectionsWithoutUnassigned$: Observable<CollectionAdminView[]>; protected allCollectionsWithoutUnassigned$: Observable<CollectionAdminView[]>;
private _flexibleCollectionsV1FlagEnabled: boolean; private _flexibleCollectionsV1FlagEnabled: boolean;
@ -160,6 +161,11 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
protected orgRevokedUsers: OrganizationUserUserDetailsResponse[]; protected orgRevokedUsers: OrganizationUserUserDetailsResponse[];
private _restrictProviderAccessFlagEnabled: boolean;
protected get restrictProviderAccessEnabled(): boolean {
return this._restrictProviderAccessFlagEnabled && this.flexibleCollectionsV1Enabled;
}
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@ -207,6 +213,10 @@ export class VaultComponent implements OnInit, OnDestroy {
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
this._restrictProviderAccessFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
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),
@ -297,10 +307,20 @@ export class VaultComponent implements OnInit, OnDestroy {
this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe(
map((collections) => { map((collections) => {
// Users that can edit all ciphers can implicitly edit all collections // If restricted, providers can not add items to any collections or edit those items
if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (this.organization.isProviderUser && this.restrictProviderAccessEnabled) {
return [];
}
// Users that can edit all ciphers can implicitly add to / edit within any collection
if (
this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
)
) {
return collections; return collections;
} }
// The user is only allowed to add/edit items to assigned collections that are not readonly
return collections.filter((c) => c.assigned && !c.readOnly); return collections.filter((c) => c.assigned && !c.readOnly);
}), }),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
@ -332,10 +352,19 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
let ciphers; let ciphers;
if (organization.isProviderUser && this.restrictProviderAccessEnabled) {
return [];
}
if (this.flexibleCollectionsV1Enabled) { if (this.flexibleCollectionsV1Enabled) {
// Flexible collections V1 logic. // Flexible collections V1 logic.
// If the user can edit all ciphers for the organization then fetch them ALL. // If the user can edit all ciphers for the organization then fetch them ALL.
if (organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
)
) {
ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
} else { } else {
// Otherwise, only fetch ciphers they have access to (includes unassigned for admins). // Otherwise, only fetch ciphers they have access to (includes unassigned for admins).
@ -343,7 +372,12 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} else { } else {
// Pre-flexible collections logic, to be removed after flexible collections is fully released // Pre-flexible collections logic, to be removed after flexible collections is fully released
if (organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
)
) {
ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id);
} else { } else {
ciphers = (await this.cipherService.getAllDecrypted()).filter( ciphers = (await this.cipherService.getAllDecrypted()).filter(
@ -443,9 +477,17 @@ export class VaultComponent implements OnInit, OnDestroy {
organization$, organization$,
]).pipe( ]).pipe(
map(([filter, collection, organization]) => { map(([filter, collection, organization]) => {
if (organization.isProviderUser && this.restrictProviderAccessEnabled) {
return collection != undefined || filter.collectionId === Unassigned;
}
return ( return (
(filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers()) || (filter.collectionId === Unassigned &&
(!organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && !organization.canEditUnassignedCiphers(this.restrictProviderAccessEnabled)) ||
(!organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
) &&
collection != undefined && collection != undefined &&
!collection.node.assigned) !collection.node.assigned)
); );
@ -490,7 +532,8 @@ export class VaultComponent implements OnInit, OnDestroy {
map(([filter, collection, organization]) => { map(([filter, collection, organization]) => {
return ( return (
// Filtering by unassigned, show message if not admin // Filtering by unassigned, show message if not admin
(filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers()) || (filter.collectionId === Unassigned &&
!organization.canEditUnassignedCiphers(this.restrictProviderAccessEnabled)) ||
// Filtering by a collection, so show message if user is not assigned // Filtering by a collection, so show message if user is not assigned
(collection != undefined && (collection != undefined &&
!collection.node.assigned && !collection.node.assigned &&
@ -513,7 +556,7 @@ export class VaultComponent implements OnInit, OnDestroy {
if (this.flexibleCollectionsV1Enabled) { if (this.flexibleCollectionsV1Enabled) {
canEditCipher = canEditCipher =
organization.canEditAllCiphers(true) || organization.canEditAllCiphers(true, this.restrictProviderAccessEnabled) ||
(await firstValueFrom(allCipherMap$))[cipherId] != undefined; (await firstValueFrom(allCipherMap$))[cipherId] != undefined;
} else { } else {
canEditCipher = canEditCipher =
@ -631,7 +674,10 @@ export class VaultComponent implements OnInit, OnDestroy {
const canEditCiphersCheck = const canEditCiphersCheck =
this._flexibleCollectionsV1FlagEnabled && this._flexibleCollectionsV1FlagEnabled &&
!this.organization.canEditAllCiphers(this._flexibleCollectionsV1FlagEnabled); !this.organization.canEditAllCiphers(
this._flexibleCollectionsV1FlagEnabled,
this.restrictProviderAccessEnabled,
);
// This custom type check will show addAccess badge for // This custom type check will show addAccess badge for
// Custom users with canEdit access AND owner/admin manage access setting is OFF // Custom users with canEdit access AND owner/admin manage access setting is OFF
@ -780,13 +826,13 @@ export class VaultComponent implements OnInit, OnDestroy {
map((c) => { map((c) => {
return c.sort((a, b) => { return c.sort((a, b) => {
if ( if (
a.canEditItems(this.organization, true) && a.canEditItems(this.organization, true, this.restrictProviderAccessEnabled) &&
!b.canEditItems(this.organization, true) !b.canEditItems(this.organization, true, this.restrictProviderAccessEnabled)
) { ) {
return -1; return -1;
} else if ( } else if (
!a.canEditItems(this.organization, true) && !a.canEditItems(this.organization, true, this.restrictProviderAccessEnabled) &&
b.canEditItems(this.organization, true) b.canEditItems(this.organization, true, this.restrictProviderAccessEnabled)
) { ) {
return 1; return 1;
} else { } else {
@ -1247,7 +1293,10 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
protected deleteCipherWithServer(id: string, permanent: boolean) { protected deleteCipherWithServer(id: string, permanent: boolean) {
const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccessEnabled,
);
return permanent return permanent
? this.cipherService.deleteWithServer(id, asAdmin) ? this.cipherService.deleteWithServer(id, asAdmin)
: this.cipherService.softDeleteWithServer(id, asAdmin); : this.cipherService.softDeleteWithServer(id, asAdmin);

View File

@ -1,6 +1,6 @@
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { BreadcrumbsModule, NoItemsModule } from "@bitwarden/components"; import { BreadcrumbsModule, NoItemsModule, SearchModule } 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";
@ -32,6 +32,7 @@ import { VaultComponent } from "./vault.component";
CollectionDialogModule, CollectionDialogModule,
CollectionAccessRestrictedComponent, CollectionAccessRestrictedComponent,
NoItemsModule, NoItemsModule,
SearchModule,
], ],
declarations: [VaultComponent, VaultHeaderComponent], declarations: [VaultComponent, VaultHeaderComponent],
exports: [VaultComponent], exports: [VaultComponent],

View File

@ -25,6 +25,7 @@ export class CollectionsComponent implements OnInit {
collections: CollectionView[] = []; collections: CollectionView[] = [];
organization: Organization; organization: Organization;
flexibleCollectionsV1Enabled: boolean; flexibleCollectionsV1Enabled: boolean;
restrictProviderAccess: boolean;
protected cipherDomain: Cipher; protected cipherDomain: Cipher;
@ -42,6 +43,9 @@ export class CollectionsComponent implements OnInit {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag( this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
this.restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
await this.load(); await this.load();
} }
@ -68,7 +72,12 @@ export class CollectionsComponent implements OnInit {
async submit(): Promise<boolean> { async submit(): Promise<boolean> {
const selectedCollectionIds = this.collections const selectedCollectionIds = this.collections
.filter((c) => { .filter((c) => {
if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (
this.organization.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
)
) {
return !!(c as any).checked; return !!(c as any).checked;
} else { } else {
return !!(c as any).checked && c.readOnly == null; return !!(c as any).checked && c.readOnly == null;

View File

@ -91,6 +91,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
private previousCipherId: string; private previousCipherId: string;
protected flexibleCollectionsV1Enabled = false; protected flexibleCollectionsV1Enabled = false;
protected restrictProviderAccess = false;
get fido2CredentialCreationDateValue(): string { get fido2CredentialCreationDateValue(): string {
const dateCreated = this.i18nService.t("dateCreated"); const dateCreated = this.i18nService.t("dateCreated");
@ -183,6 +184,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag( this.flexibleCollectionsV1Enabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollectionsV1, FeatureFlag.FlexibleCollectionsV1,
); );
this.restrictProviderAccess = await this.configService.getFeatureFlag(
FeatureFlag.RestrictProviderAccess,
);
this.policyService this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership) .policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
@ -668,11 +672,14 @@ 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;
let orgAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); let orgAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection // if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
if (!cipher.collectionIds) { if (!cipher.collectionIds) {
orgAdmin = this.organization?.canEditUnassignedCiphers(); orgAdmin = this.organization?.canEditUnassignedCiphers(this.restrictProviderAccess);
} }
return this.cipher.id == null return this.cipher.id == null
@ -681,14 +688,20 @@ export class AddEditComponent implements OnInit, OnDestroy {
} }
protected deleteCipher() { protected deleteCipher() {
const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
return this.cipher.isDeleted return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, asAdmin) ? this.cipherService.deleteWithServer(this.cipher.id, asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin); : this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);
} }
protected restoreCipher() { protected restoreCipher() {
const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); const asAdmin = this.organization?.canEditAllCiphers(
this.flexibleCollectionsV1Enabled,
this.restrictProviderAccess,
);
return this.cipherService.restoreWithServer(this.cipher.id, asAdmin); return this.cipherService.restoreWithServer(this.cipher.id, asAdmin);
} }

View File

@ -203,22 +203,32 @@ export class Organization {
); );
} }
canEditUnassignedCiphers() { canEditUnassignedCiphers(restrictProviderAccessFlagEnabled: boolean) {
// TODO: Update this to exclude Providers if provider access is restricted in AC-1707 if (this.isProviderUser) {
return !restrictProviderAccessFlagEnabled;
}
return this.isAdmin || this.permissions.editAnyCollection; return this.isAdmin || this.permissions.editAnyCollection;
} }
canEditAllCiphers(flexibleCollectionsV1Enabled: boolean) { canEditAllCiphers(
flexibleCollectionsV1Enabled: boolean,
restrictProviderAccessFlagEnabled: boolean,
) {
// Before Flexible Collections, any admin or anyone with editAnyCollection permission could edit all ciphers // Before Flexible Collections, any admin or anyone with editAnyCollection permission could edit all ciphers
if (!this.flexibleCollections || !flexibleCollectionsV1Enabled) { if (!this.flexibleCollections || !flexibleCollectionsV1Enabled || !this.flexibleCollections) {
return this.isAdmin || this.permissions.editAnyCollection; return this.isAdmin || this.permissions.editAnyCollection;
} }
if (this.isProviderUser) {
return !restrictProviderAccessFlagEnabled;
}
// Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins // Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins
// Providers and custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag // Custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag
return ( return (
this.isProviderUser ||
(this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) || (this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) ||
(this.allowAdminAccessToAllCollectionItems && this.isAdmin) (this.allowAdminAccessToAllCollectionItems &&
(this.type === OrganizationUserType.Admin || this.type === OrganizationUserType.Owner))
); );
} }

View File

@ -17,6 +17,7 @@ export enum FeatureFlag {
UnassignedItemsBanner = "unassigned-items-banner", UnassignedItemsBanner = "unassigned-items-banner",
EnableDeleteProvider = "AC-1218-delete-provider", EnableDeleteProvider = "AC-1218-delete-provider",
ExtensionRefresh = "extension-refresh", ExtensionRefresh = "extension-refresh",
RestrictProviderAccess = "restrict-provider-access",
} }
export type AllowedFeatureFlagTypes = boolean | number | string; export type AllowedFeatureFlagTypes = boolean | number | string;
@ -44,6 +45,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnassignedItemsBanner]: FALSE, [FeatureFlag.UnassignedItemsBanner]: FALSE,
[FeatureFlag.EnableDeleteProvider]: FALSE, [FeatureFlag.EnableDeleteProvider]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE, [FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.RestrictProviderAccess]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>; } satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@ -39,7 +39,11 @@ export class CollectionView implements View, ITreeNodeObject {
} }
} }
canEditItems(org: Organization, v1FlexibleCollections: boolean): boolean { canEditItems(
org: Organization,
v1FlexibleCollections: boolean,
restrictProviderAccess: boolean,
): boolean {
if (org != null && org.id !== this.organizationId) { if (org != null && org.id !== this.organizationId) {
throw new Error( throw new Error(
"Id of the organization provided does not match the org id of the collection.", "Id of the organization provided does not match the org id of the collection.",
@ -48,7 +52,7 @@ export class CollectionView implements View, ITreeNodeObject {
if (org?.flexibleCollections) { if (org?.flexibleCollections) {
return ( return (
org?.canEditAllCiphers(v1FlexibleCollections) || org?.canEditAllCiphers(v1FlexibleCollections, restrictProviderAccess) ||
this.manage || this.manage ||
(this.assigned && !this.readOnly) (this.assigned && !this.readOnly)
); );