1
0
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:
Shane Melton
2024-02-08 14:07:42 -08:00
committed by GitHub
parent 3ee27fc61f
commit 5c6245aaae
14 changed files with 284 additions and 76 deletions

View File

@@ -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;
}
/**