mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 22:33:35 +00:00
[AC-2170] Group modal - limit admin access - collections tab (#8758)
* Update Group modal -> Collections tab to respect collection management settings, e.g. only allow admins to assign access to collections they can manage * Update collectionAdminView getters for custom permissions
This commit is contained in:
@@ -50,7 +50,12 @@
|
|||||||
</bit-tab>
|
</bit-tab>
|
||||||
|
|
||||||
<bit-tab label="{{ 'collections' | i18n }}">
|
<bit-tab label="{{ 'collections' | i18n }}">
|
||||||
<p>{{ "editGroupCollectionsDesc" | i18n }}</p>
|
<p>
|
||||||
|
{{ "editGroupCollectionsDesc" | i18n }}
|
||||||
|
<span *ngIf="!(allowAdminAccessToAllCollectionItems$ | async)">
|
||||||
|
{{ "editGroupCollectionsRestrictionsDesc" | i18n }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
<div *ngIf="!(flexibleCollectionsEnabled$ | async)" class="tw-my-3">
|
<div *ngIf="!(flexibleCollectionsEnabled$ | async)" class="tw-my-3">
|
||||||
<input type="checkbox" formControlName="accessAll" id="accessAll" />
|
<input type="checkbox" formControlName="accessAll" id="accessAll" />
|
||||||
<label class="tw-mb-0 tw-text-lg" for="accessAll">{{
|
<label class="tw-mb-0 tw-text-lg" for="accessAll">{{
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import {
|
|||||||
of,
|
of,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
Subject,
|
Subject,
|
||||||
switchMap,
|
|
||||||
takeUntil,
|
takeUntil,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.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 { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||||
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
@@ -26,12 +26,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
|||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
|
||||||
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
|
|
||||||
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
|
|
||||||
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
|
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { CollectionAdminService } from "../../../vault/core/collection-admin.service";
|
||||||
|
import { CollectionAdminView } from "../../../vault/core/views/collection-admin.view";
|
||||||
import { InternalGroupService as GroupService, GroupView } from "../core";
|
import { InternalGroupService as GroupService, GroupView } from "../core";
|
||||||
import {
|
import {
|
||||||
AccessItemType,
|
AccessItemType,
|
||||||
@@ -95,9 +93,15 @@ export const openGroupAddEditDialog = (
|
|||||||
templateUrl: "group-add-edit.component.html",
|
templateUrl: "group-add-edit.component.html",
|
||||||
})
|
})
|
||||||
export class GroupAddEditComponent implements OnInit, OnDestroy {
|
export class GroupAddEditComponent implements OnInit, OnDestroy {
|
||||||
protected flexibleCollectionsEnabled$ = this.organizationService
|
private organization$ = this.organizationService
|
||||||
.get$(this.organizationId)
|
.get$(this.organizationId)
|
||||||
.pipe(map((o) => o?.flexibleCollections));
|
.pipe(shareReplay({ refCount: true }));
|
||||||
|
protected flexibleCollectionsEnabled$ = this.organization$.pipe(
|
||||||
|
map((o) => o?.flexibleCollections),
|
||||||
|
);
|
||||||
|
private flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.FlexibleCollectionsV1,
|
||||||
|
);
|
||||||
|
|
||||||
protected PermissionMode = PermissionMode;
|
protected PermissionMode = PermissionMode;
|
||||||
protected ResultType = GroupAddEditDialogResultType;
|
protected ResultType = GroupAddEditDialogResultType;
|
||||||
@@ -131,27 +135,9 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
private get orgCollections$() {
|
private orgCollections$ = from(this.collectionAdminService.getAll(this.organizationId)).pipe(
|
||||||
return from(this.apiService.getCollections(this.organizationId)).pipe(
|
shareReplay({ refCount: false }),
|
||||||
switchMap((response) => {
|
);
|
||||||
return from(
|
|
||||||
this.collectionService.decryptMany(
|
|
||||||
response.data.map(
|
|
||||||
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
map((collections) =>
|
|
||||||
collections.map<AccessItemView>((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
type: AccessItemType.Collection,
|
|
||||||
labelName: c.name,
|
|
||||||
listName: c.name,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> {
|
private get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> {
|
||||||
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
|
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
|
||||||
@@ -197,23 +183,24 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
|||||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
restrictGroupAccess$ = combineLatest([
|
allowAdminAccessToAllCollectionItems$ = combineLatest([
|
||||||
this.organizationService.get$(this.organizationId),
|
this.organization$,
|
||||||
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
|
this.flexibleCollectionsV1Enabled$,
|
||||||
this.groupDetails$,
|
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(
|
map(([organization, flexibleCollectionsV1Enabled]) => {
|
||||||
([organization, flexibleCollectionsV1Enabled, group]) =>
|
if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
|
||||||
// Feature flag conditionals
|
return true;
|
||||||
flexibleCollectionsV1Enabled &&
|
}
|
||||||
organization.flexibleCollections &&
|
|
||||||
// Business logic conditionals
|
return organization.allowAdminAccessToAllCollectionItems;
|
||||||
!organization.allowAdminAccessToAllCollectionItems &&
|
}),
|
||||||
group !== undefined,
|
|
||||||
),
|
|
||||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
restrictGroupAccess$ = combineLatest([
|
||||||
|
this.allowAdminAccessToAllCollectionItems$,
|
||||||
|
this.groupDetails$,
|
||||||
|
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
|
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
|
||||||
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
|
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
|
||||||
@@ -221,7 +208,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
|||||||
private organizationUserService: OrganizationUserService,
|
private organizationUserService: OrganizationUserService,
|
||||||
private groupService: GroupService,
|
private groupService: GroupService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private collectionService: CollectionService,
|
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
@@ -230,6 +216,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
|||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
|
private collectionAdminService: CollectionAdminService,
|
||||||
) {
|
) {
|
||||||
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
|
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
|
||||||
}
|
}
|
||||||
@@ -244,48 +231,61 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
|||||||
this.groupDetails$,
|
this.groupDetails$,
|
||||||
this.restrictGroupAccess$,
|
this.restrictGroupAccess$,
|
||||||
this.accountService.activeAccount$,
|
this.accountService.activeAccount$,
|
||||||
|
this.organization$,
|
||||||
|
this.flexibleCollectionsV1Enabled$,
|
||||||
])
|
])
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(([collections, members, group, restrictGroupAccess, activeAccount]) => {
|
.subscribe(
|
||||||
this.collections = collections;
|
([
|
||||||
this.members = members;
|
collections,
|
||||||
this.group = group;
|
members,
|
||||||
|
group,
|
||||||
if (this.group != undefined) {
|
restrictGroupAccess,
|
||||||
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
|
activeAccount,
|
||||||
// collections/members set above, otherwise no selected values will be patched below
|
organization,
|
||||||
this.changeDetectorRef.detectChanges();
|
flexibleCollectionsV1Enabled,
|
||||||
|
]) => {
|
||||||
this.groupForm.patchValue({
|
this.members = members;
|
||||||
name: this.group.name,
|
this.group = group;
|
||||||
externalId: this.group.externalId,
|
this.collections = mapToAccessItemViews(
|
||||||
accessAll: this.group.accessAll,
|
collections,
|
||||||
members: this.group.members.map((m) => ({
|
organization,
|
||||||
id: m,
|
flexibleCollectionsV1Enabled,
|
||||||
type: AccessItemType.Member,
|
group,
|
||||||
})),
|
|
||||||
collections: this.group.collections.map((gc) => ({
|
|
||||||
id: gc.id,
|
|
||||||
type: AccessItemType.Collection,
|
|
||||||
permission: convertToPermission(gc),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the current user is not already in the group and cannot add themselves, remove them from the list
|
|
||||||
if (restrictGroupAccess) {
|
|
||||||
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
|
|
||||||
const isAlreadyInGroup = this.groupForm.value.members.some(
|
|
||||||
(m) => m.id === organizationUserId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isAlreadyInGroup) {
|
if (this.group != undefined) {
|
||||||
this.members = this.members.filter((m) => m.id !== organizationUserId);
|
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
|
||||||
}
|
// collections/members set above, otherwise no selected values will be patched below
|
||||||
}
|
this.changeDetectorRef.detectChanges();
|
||||||
|
|
||||||
this.loading = false;
|
this.groupForm.patchValue({
|
||||||
});
|
name: this.group.name,
|
||||||
|
externalId: this.group.externalId,
|
||||||
|
accessAll: this.group.accessAll,
|
||||||
|
members: this.group.members.map((m) => ({
|
||||||
|
id: m,
|
||||||
|
type: AccessItemType.Member,
|
||||||
|
})),
|
||||||
|
collections: mapToAccessSelections(group, this.collections),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the current user is not already in the group and cannot add themselves, remove them from the list
|
||||||
|
if (restrictGroupAccess) {
|
||||||
|
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
|
||||||
|
const isAlreadyInGroup = this.groupForm.value.members.some(
|
||||||
|
(m) => m.id === organizationUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAlreadyInGroup) {
|
||||||
|
this.members = this.members.filter((m) => m.id !== organizationUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@@ -355,3 +355,46 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
|
|||||||
this.dialogRef.close(GroupAddEditDialogResultType.Deleted);
|
this.dialogRef.close(GroupAddEditDialogResultType.Deleted);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the group's current collection access to AccessItemValues to populate the access-selector's FormControl
|
||||||
|
*/
|
||||||
|
function mapToAccessSelections(group: GroupView, items: AccessItemView[]): AccessItemValue[] {
|
||||||
|
return (
|
||||||
|
group.collections
|
||||||
|
// The FormControl value only represents editable collection access - exclude readonly access selections
|
||||||
|
.filter((selection) => !items.find((item) => item.id == selection.id).readonly)
|
||||||
|
.map((gc) => ({
|
||||||
|
id: gc.id,
|
||||||
|
type: AccessItemType.Collection,
|
||||||
|
permission: convertToPermission(gc),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the organization's collections to AccessItemViews to populate the access-selector's multi-select
|
||||||
|
*/
|
||||||
|
function mapToAccessItemViews(
|
||||||
|
collections: CollectionAdminView[],
|
||||||
|
organization: Organization,
|
||||||
|
flexibleCollectionsV1Enabled: boolean,
|
||||||
|
group?: GroupView,
|
||||||
|
): AccessItemView[] {
|
||||||
|
return (
|
||||||
|
collections
|
||||||
|
.map<AccessItemView>((c) => {
|
||||||
|
const accessSelection = group?.collections.find((access) => access.id == c.id) ?? undefined;
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
type: AccessItemType.Collection,
|
||||||
|
labelName: c.name,
|
||||||
|
listName: c.name,
|
||||||
|
readonly: !c.canEditGroupAccess(organization, flexibleCollectionsV1Enabled),
|
||||||
|
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// Remove any collection views that are not already assigned and that we don't have permissions to assign access to
|
||||||
|
.filter((item) => !item.readonly || group?.collections.some((access) => access.id == item.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ export class CollectionAdminView extends CollectionView {
|
|||||||
* Whether the user can modify user access to this collection
|
* Whether the user can modify user access to this collection
|
||||||
*/
|
*/
|
||||||
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
|
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
|
||||||
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.canManageUsers;
|
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user can modify group access to this collection
|
||||||
|
*/
|
||||||
|
canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
|
||||||
|
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6480,6 +6480,9 @@
|
|||||||
"editGroupCollectionsDesc": {
|
"editGroupCollectionsDesc": {
|
||||||
"message": "Grant access to collections by adding them to this group."
|
"message": "Grant access to collections by adding them to this group."
|
||||||
},
|
},
|
||||||
|
"editGroupCollectionsRestrictionsDesc": {
|
||||||
|
"message": "You can only assign collections you manage."
|
||||||
|
},
|
||||||
"accessAllCollectionsDesc": {
|
"accessAllCollectionsDesc": {
|
||||||
"message": "Grant access to all current and future collections."
|
"message": "Grant access to all current and future collections."
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user