mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 23:33:31 +00:00
[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:
@@ -127,13 +127,20 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected collections: CollectionAdminView[];
|
||||
protected selectedCollection: TreeNode<CollectionAdminView> | 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<string>;
|
||||
protected editableCollections$: Observable<CollectionView[]>;
|
||||
protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.BulkCollectionAccess,
|
||||
false,
|
||||
);
|
||||
protected flexibleCollectionsEnabled: boolean;
|
||||
protected flexibleCollectionsV1Enabled: boolean;
|
||||
|
||||
private searchText$ = new Subject<string>();
|
||||
private refresh$ = new BehaviorSubject<void>(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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user