1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

[PM-22717] Expose DefaultUserCollectionEmail to clients (#15643)

* enforce restrictions based on collection type, set default collection type

* fix ts strict errors

* fix default collection enforcement in vault header

* enforce default collection restrictions in vault collection row

* enforce default collection restrictions in AC vault header

* enforce default collection restriction for select all

* fix ts strict error

* switch to signal, fix feature flag

* fix story

* clean up

* remove feature flag, move check for defaultCollecion to CollecitonView

* fix test

* remove unused configService

* fix test: coerce null to undefined for collection Id

* clean up leaky abstraction for default collection

* fix ts-strict error

* fix parens

* add new property to models, update logic, refactor for ts-strict

* fix type

* rename defaultCollection getter

* clean up

* clean up

* clean up, add comment, fix submit

* add comment

* add feature flag

* check model for name

* cleanup readonly logic, remove featureflag logic

* wip

* refactor CollectionRequest into Create and Update models

* fix readonly logic

* cleanup

* set defaultUserCollectionEmail in decryption from Collection

* split save into update/create methods

* fix readonly logic

* fix collections post and put requests

* add defaultUserCollection email to model when submitting collection dialog
This commit is contained in:
Brandon Treston
2025-08-26 11:42:52 -04:00
committed by GitHub
parent ad2dfe1e99
commit 28b5a2bb5e
20 changed files with 248 additions and 79 deletions

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { firstValueFrom, map, switchMap } from "rxjs"; import { firstValueFrom, map, switchMap } from "rxjs";
import { CollectionRequest } from "@bitwarden/admin-console/common"; import { UpdateCollectionRequest } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
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 { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -225,7 +225,7 @@ export class EditCommand {
: req.users.map( : req.users.map(
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage), (u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
); );
const request = new CollectionRequest({ const request = new UpdateCollectionRequest({
name: await this.encryptService.encryptString(req.name, orgKey), name: await this.encryptService.encryptString(req.name, orgKey),
externalId: req.externalId, externalId: req.externalId,
users, users,

View File

@@ -5,7 +5,7 @@ import * as path from "path";
import { firstValueFrom, map } from "rxjs"; import { firstValueFrom, map } from "rxjs";
import { CollectionRequest } from "@bitwarden/admin-console/common"; import { CreateCollectionRequest } from "@bitwarden/admin-console/common";
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 { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
@@ -233,7 +233,7 @@ export class CreateCommand {
: req.users.map( : req.users.map(
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage), (u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage),
); );
const request = new CollectionRequest({ const request = new CreateCollectionRequest({
name: await this.encryptService.encryptString(req.name, orgKey), name: await this.encryptService.encryptString(req.name, orgKey),
externalId: req.externalId, externalId: req.externalId,
groups, groups,

View File

@@ -45,9 +45,15 @@ export function cloneCollection(
let cloned; let cloned;
if (collection instanceof CollectionAdminView) { if (collection instanceof CollectionAdminView) {
cloned = Object.assign(new CollectionAdminView({ ...collection }), collection); cloned = Object.assign(
new CollectionAdminView({ ...collection, name: collection.name }),
collection,
);
} else { } else {
cloned = Object.assign(new CollectionView({ ...collection }), collection); cloned = Object.assign(
new CollectionView({ ...collection, name: collection.name }),
collection,
);
} }
return cloned; return cloned;
} }

View File

@@ -398,6 +398,13 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
} }
return; return;
} }
if (
this.editMode &&
!this.collection.canEditName(this.organization) &&
this.formGroup.controls.name.dirty
) {
throw new Error("Cannot change readonly field: Name");
}
const parent = this.formGroup.controls.parent?.value; const parent = this.formGroup.controls.parent?.value;
const collectionView = new CollectionAdminView({ const collectionView = new CollectionAdminView({
@@ -414,9 +421,13 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
collectionView.users = this.formGroup.controls.access.value collectionView.users = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Member) .filter((v) => v.type === AccessItemType.Member)
.map(convertToSelectionView); .map(convertToSelectionView);
collectionView.defaultUserCollectionEmail = this.collection.defaultUserCollectionEmail;
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const savedCollection = await this.collectionAdminService.save(collectionView, userId);
const collectionResponse = this.editMode
? await this.collectionAdminService.update(collectionView, userId)
: await this.collectionAdminService.create(collectionView, userId);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
@@ -426,7 +437,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
), ),
}); });
this.close(CollectionDialogAction.Saved, savedCollection); this.close(CollectionDialogAction.Saved, collectionResponse);
}; };
protected delete = async () => { protected delete = async () => {
@@ -483,14 +494,23 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private handleFormGroupReadonly(readonly: boolean) { private handleFormGroupReadonly(readonly: boolean) {
if (readonly) { if (readonly) {
this.formGroup.controls.access.disable();
this.formGroup.controls.name.disable(); this.formGroup.controls.name.disable();
this.formGroup.controls.parent.disable(); this.formGroup.controls.parent.disable();
this.formGroup.controls.access.disable(); return;
} else { }
this.formGroup.controls.access.enable();
if (!this.editMode) {
this.formGroup.controls.name.enable(); this.formGroup.controls.name.enable();
this.formGroup.controls.parent.enable(); this.formGroup.controls.parent.enable();
this.formGroup.controls.access.enable(); return;
} }
const canEditName = this.collection.canEditName(this.organization);
this.formGroup.controls.name[canEditName ? "enable" : "disable"]();
this.formGroup.controls.parent[canEditName ? "enable" : "disable"]();
} }
private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) { private close(action: CollectionDialogAction, collection?: CollectionResponse | CollectionView) {

View File

@@ -45,6 +45,7 @@ import {
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { import {
InternalPolicyService, InternalPolicyService,
@@ -317,7 +318,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: CollectionAdminService, provide: CollectionAdminService,
useClass: DefaultCollectionAdminService, useClass: DefaultCollectionAdminService,
deps: [ApiService, KeyServiceAbstraction, EncryptService, CollectionService], deps: [
ApiService,
KeyServiceAbstraction,
EncryptService,
CollectionService,
OrganizationService,
],
}), }),
safeProvider({ safeProvider({
provide: SdkLoadService, provide: SdkLoadService,

View File

@@ -250,7 +250,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
} }
collections.forEach((c) => { collections.forEach((c) => {
const collectionCopy = cloneCollection(new CollectionView({ ...c })) as CollectionFilter; const collectionCopy = cloneCollection(
new CollectionView({ ...c, name: c.name }),
) as CollectionFilter;
collectionCopy.icon = "bwi-collection-shared"; collectionCopy.icon = "bwi-collection-shared";
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);

View File

@@ -10,7 +10,11 @@ export abstract class CollectionAdminService {
organizationId: string, organizationId: string,
userId: UserId, userId: UserId,
): Observable<CollectionAdminView[]>; ): Observable<CollectionAdminView[]>;
abstract save( abstract update(
collection: CollectionAdminView,
userId: UserId,
): Promise<CollectionDetailsResponse>;
abstract create(
collection: CollectionAdminView, collection: CollectionAdminView,
userId: UserId, userId: UserId,
): Promise<CollectionDetailsResponse>; ): Promise<CollectionDetailsResponse>;

View File

@@ -101,6 +101,17 @@ export class CollectionAdminView extends CollectionView {
return this.id === Unassigned; return this.id === Unassigned;
} }
/**
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
* that were DefaultUserCollections but where the relevant user has been offboarded.
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
* Do not edit or remove this unless you understand why.
*/
override canEditName(org: Organization): boolean {
return (this.canEdit(org) && !this.defaultUserCollectionEmail) || super.canEditName(org);
}
static async fromCollectionAccessDetails( static async fromCollectionAccessDetails(
collection: CollectionAccessDetailsResponse, collection: CollectionAccessDetailsResponse,
encryptService: EncryptService, encryptService: EncryptService,
@@ -115,6 +126,7 @@ export class CollectionAdminView extends CollectionView {
view.unmanaged = collection.unmanaged; view.unmanaged = collection.unmanaged;
view.type = collection.type; view.type = collection.type;
view.externalId = collection.externalId; view.externalId = collection.externalId;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
view.groups = collection.groups view.groups = collection.groups
? collection.groups.map((g) => new CollectionAccessSelectionView(g)) ? collection.groups.map((g) => new CollectionAccessSelectionView(g))

View File

@@ -1,19 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Collection } from "./collection"; import { Collection } from "./collection";
import { CollectionRequest } from "./collection.request"; import { BaseCollectionRequest } from "./collection.request";
export class CollectionWithIdRequest extends CollectionRequest { export class CollectionWithIdRequest extends BaseCollectionRequest {
id: string; id: string;
name: string;
constructor(collection?: Collection) { constructor(collection: Collection) {
if (collection == null) { if (collection == null || collection.name == null || collection.name.encryptedString == null) {
return; throw new Error("CollectionWithIdRequest must contain name.");
} }
super({ super({
name: collection.name,
externalId: collection.externalId, externalId: collection.externalId,
}); });
this.name = collection.name.encryptedString;
this.id = collection.id; this.id = collection.id;
} }
} }

View File

@@ -9,6 +9,7 @@ export class CollectionData {
id: CollectionId; id: CollectionId;
organizationId: OrganizationId; organizationId: OrganizationId;
name: string; name: string;
defaultUserCollectionEmail: string | undefined;
externalId: string | undefined; externalId: string | undefined;
readOnly: boolean = false; readOnly: boolean = false;
manage: boolean = false; manage: boolean = false;
@@ -24,6 +25,7 @@ export class CollectionData {
this.manage = response.manage; this.manage = response.manage;
this.hidePasswords = response.hidePasswords; this.hidePasswords = response.hidePasswords;
this.type = response.type; this.type = response.type;
this.defaultUserCollectionEmail = response.defaultUserCollectionEmail;
} }
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null { static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {

View File

@@ -1,23 +1,20 @@
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
export class CollectionRequest { export abstract class BaseCollectionRequest {
name: string;
externalId: string | undefined; externalId: string | undefined;
groups: SelectionReadOnlyRequest[] = []; groups: SelectionReadOnlyRequest[] = [];
users: SelectionReadOnlyRequest[] = []; users: SelectionReadOnlyRequest[] = [];
constructor(c: { static isUpdate = (request: BaseCollectionRequest): request is UpdateCollectionRequest => {
name: EncString; return request instanceof UpdateCollectionRequest;
};
protected constructor(c: {
users?: SelectionReadOnlyRequest[]; users?: SelectionReadOnlyRequest[];
groups?: SelectionReadOnlyRequest[]; groups?: SelectionReadOnlyRequest[];
externalId?: string; externalId?: string;
}) { }) {
if (!c.name || !c.name.encryptedString) {
throw new Error("Name not provided for CollectionRequest.");
}
this.name = c.name.encryptedString;
this.externalId = c.externalId; this.externalId = c.externalId;
if (c.groups) { if (c.groups) {
@@ -28,3 +25,36 @@ export class CollectionRequest {
} }
} }
} }
export class CreateCollectionRequest extends BaseCollectionRequest {
name: string;
constructor(c: {
name: EncString;
users?: SelectionReadOnlyRequest[];
groups?: SelectionReadOnlyRequest[];
externalId?: string;
}) {
super(c);
if (!c.name || !c.name.encryptedString) {
throw new Error("Name not provided for CollectionRequest.");
}
this.name = c.name.encryptedString;
}
}
export class UpdateCollectionRequest extends BaseCollectionRequest {
name: string | null;
constructor(c: {
name: EncString | null;
users?: SelectionReadOnlyRequest[];
groups?: SelectionReadOnlyRequest[];
externalId?: string;
}) {
super(c);
this.name = c.name?.encryptedString ?? null;
}
}

View File

@@ -8,6 +8,7 @@ export class CollectionResponse extends BaseResponse {
id: CollectionId; id: CollectionId;
organizationId: OrganizationId; organizationId: OrganizationId;
name: string; name: string;
defaultUserCollectionEmail: string | undefined;
externalId: string | undefined; externalId: string | undefined;
type: CollectionType = CollectionTypes.SharedCollection; type: CollectionType = CollectionTypes.SharedCollection;
@@ -17,6 +18,7 @@ export class CollectionResponse extends BaseResponse {
this.organizationId = this.getResponseProperty("OrganizationId"); this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name"); this.name = this.getResponseProperty("Name");
this.externalId = this.getResponseProperty("ExternalId"); this.externalId = this.getResponseProperty("ExternalId");
this.defaultUserCollectionEmail = this.getResponseProperty("DefaultUserCollectionEmail");
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection; this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
} }
} }

View File

@@ -25,6 +25,7 @@ describe("Collection", () => {
manage: true, manage: true,
hidePasswords: true, hidePasswords: true,
type: CollectionTypes.DefaultUserCollection, type: CollectionTypes.DefaultUserCollection,
defaultUserCollectionEmail: "defaultCollectionEmail",
}), }),
); );
encService = mock<EncryptService>(); encService = mock<EncryptService>();
@@ -61,6 +62,7 @@ describe("Collection", () => {
manage: true, manage: true,
hidePasswords: true, hidePasswords: true,
type: CollectionTypes.DefaultUserCollection, type: CollectionTypes.DefaultUserCollection,
defaultUserCollectionEmail: "defaultCollectionEmail",
}); });
}); });
@@ -75,6 +77,7 @@ describe("Collection", () => {
collection.hidePasswords = false; collection.hidePasswords = false;
collection.manage = true; collection.manage = true;
collection.type = CollectionTypes.DefaultUserCollection; collection.type = CollectionTypes.DefaultUserCollection;
collection.defaultUserCollectionEmail = "defaultCollectionEmail";
const key = makeSymmetricCryptoKey<OrgKey>(); const key = makeSymmetricCryptoKey<OrgKey>();
@@ -84,12 +87,13 @@ describe("Collection", () => {
externalId: "extId", externalId: "extId",
hidePasswords: false, hidePasswords: false,
id: "id", id: "id",
name: "encName", _name: "encName",
organizationId: "orgId", organizationId: "orgId",
readOnly: false, readOnly: false,
manage: true, manage: true,
assigned: true, assigned: true,
type: CollectionTypes.DefaultUserCollection, type: CollectionTypes.DefaultUserCollection,
defaultUserCollectionEmail: "defaultCollectionEmail",
}); });
}); });
}); });

View File

@@ -23,6 +23,7 @@ export class Collection extends Domain {
hidePasswords: boolean = false; hidePasswords: boolean = false;
manage: boolean = false; manage: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection; type: CollectionType = CollectionTypes.SharedCollection;
defaultUserCollectionEmail: string | undefined;
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) { constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
super(); super();
@@ -46,6 +47,7 @@ export class Collection extends Domain {
collection.hidePasswords = obj.hidePasswords; collection.hidePasswords = obj.hidePasswords;
collection.manage = obj.manage; collection.manage = obj.manage;
collection.type = obj.type; collection.type = obj.type;
collection.defaultUserCollectionEmail = obj.defaultUserCollectionEmail;
return collection; return collection;
} }

View File

@@ -16,7 +16,6 @@ export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject { export class CollectionView implements View, ITreeNodeObject {
id: CollectionId; id: CollectionId;
organizationId: OrganizationId; organizationId: OrganizationId;
name: string;
externalId: string | undefined; externalId: string | undefined;
// readOnly applies to the items within a collection // readOnly applies to the items within a collection
readOnly: boolean = false; readOnly: boolean = false;
@@ -24,11 +23,22 @@ export class CollectionView implements View, ITreeNodeObject {
manage: boolean = false; manage: boolean = false;
assigned: boolean = false; assigned: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection; type: CollectionType = CollectionTypes.SharedCollection;
defaultUserCollectionEmail: string | undefined;
private _name: string;
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) { constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
this.id = c.id; this.id = c.id;
this.organizationId = c.organizationId; this.organizationId = c.organizationId;
this.name = c.name; this._name = c.name;
}
set name(name: string) {
this._name = name;
}
get name(): string {
return this.defaultUserCollectionEmail ?? this._name;
} }
canEditItems(org: Organization): boolean { canEditItems(org: Organization): boolean {
@@ -83,6 +93,18 @@ export class CollectionView implements View, ITreeNodeObject {
return false; return false;
} }
/**
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
* that were DefaultUserCollections but where the relevant user has been offboarded.
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
* Do not edit or remove this unless you understand why.
*/
canEditName(org: Organization): boolean {
return this.canEdit(org) && !this.defaultUserCollectionEmail;
}
get isDefaultCollection() { get isDefaultCollection() {
return this.type == CollectionTypes.DefaultUserCollection; return this.type == CollectionTypes.DefaultUserCollection;
} }
@@ -111,6 +133,7 @@ export class CollectionView implements View, ITreeNodeObject {
view.hidePasswords = collection.hidePasswords; view.hidePasswords = collection.hidePasswords;
view.manage = collection.manage; view.manage = collection.manage;
view.type = collection.type; view.type = collection.type;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
return view; return view;
} }
@@ -125,6 +148,7 @@ export class CollectionView implements View, ITreeNodeObject {
view.externalId = collection.externalId; view.externalId = collection.externalId;
view.type = collection.type; view.type = collection.type;
view.assigned = collection.assigned; view.assigned = collection.assigned;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
return view; return view;
} }

View File

@@ -1,6 +1,10 @@
import { combineLatest, firstValueFrom, from, map, Observable, of, switchMap } from "rxjs"; import { combineLatest, firstValueFrom, from, map, Observable, of, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -10,13 +14,15 @@ import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions"; import { CollectionAdminService, CollectionService } from "../abstractions";
import { import {
CollectionData, CollectionData,
CollectionRequest,
CollectionAccessDetailsResponse, CollectionAccessDetailsResponse,
CollectionDetailsResponse, CollectionDetailsResponse,
CollectionResponse, CollectionResponse,
BulkCollectionAccessRequest, BulkCollectionAccessRequest,
CollectionAccessSelectionView, CollectionAccessSelectionView,
CollectionAdminView, CollectionAdminView,
BaseCollectionRequest,
UpdateCollectionRequest,
CreateCollectionRequest,
} from "../models"; } from "../models";
export class DefaultCollectionAdminService implements CollectionAdminService { export class DefaultCollectionAdminService implements CollectionAdminService {
@@ -25,6 +31,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
private keyService: KeyService, private keyService: KeyService,
private encryptService: EncryptService, private encryptService: EncryptService,
private collectionService: CollectionService, private collectionService: CollectionService,
private organizationService: OrganizationService,
) {} ) {}
collectionAdminViews$(organizationId: string, userId: UserId): Observable<CollectionAdminView[]> { collectionAdminViews$(organizationId: string, userId: UserId): Observable<CollectionAdminView[]> {
@@ -45,27 +52,40 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
); );
} }
async save(collection: CollectionAdminView, userId: UserId): Promise<CollectionDetailsResponse> { async update(
const request = await this.encrypt(collection, userId); collection: CollectionAdminView,
userId: UserId,
let response: CollectionDetailsResponse; ): Promise<CollectionDetailsResponse> {
if (collection.id == null) { const request = await this.encrypt(collection, userId, true);
response = await this.apiService.postCollection(collection.organizationId, request); if (!BaseCollectionRequest.isUpdate(request)) {
collection.id = response.id; throw new Error("Cannot update collection with CreateCollectionRequest.");
} else {
response = await this.apiService.putCollection(
collection.organizationId,
collection.id,
request,
);
} }
if (response.assigned) { const response = await this.apiService.putCollection(
await this.collectionService.upsert(new CollectionData(response), userId); collection.organizationId,
} else { collection.id,
await this.collectionService.delete([collection.id as CollectionId], userId); request,
);
await this.updateLocalCollections(response, collection, userId);
return response;
}
async create(
collection: CollectionAdminView,
userId: UserId,
): Promise<CollectionDetailsResponse> {
const request = await this.encrypt(collection, userId, false);
if (BaseCollectionRequest.isUpdate(request)) {
throw new Error("Cannot create collection with UpdateCollectionRequest.");
} }
const response = await this.apiService.postCollection(collection.organizationId, request);
collection.id = response.id;
await this.updateLocalCollections(response, collection, userId);
return response; return response;
} }
@@ -73,6 +93,16 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
await this.apiService.deleteCollection(organizationId, collectionId); await this.apiService.deleteCollection(organizationId, collectionId);
} }
private async updateLocalCollections(
response: CollectionDetailsResponse,
collection: CollectionAdminView,
userId: UserId,
) {
response.assigned
? await this.collectionService.upsert(new CollectionData(response), userId)
: await this.collectionService.delete([collection.id as CollectionId], userId);
}
async bulkAssignAccess( async bulkAssignAccess(
organizationId: string, organizationId: string,
collectionIds: string[], collectionIds: string[],
@@ -118,10 +148,15 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
); );
}); });
return await Promise.all(promises); const r = await Promise.all(promises);
return r;
} }
private async encrypt(model: CollectionAdminView, userId: UserId): Promise<CollectionRequest> { private async encrypt(
model: CollectionAdminView,
userId: UserId,
editMode: boolean,
): Promise<UpdateCollectionRequest | CreateCollectionRequest> {
if (!model.organizationId) { if (!model.organizationId) {
throw new Error("Collection has no organization id."); throw new Error("Collection has no organization id.");
} }
@@ -154,14 +189,31 @@ export class DefaultCollectionAdminService implements CollectionAdminService {
new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage), new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage),
); );
const collectionRequest = new CollectionRequest({ if (editMode) {
const org = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(model.organizationId)),
);
if (org == null) {
throw new Error("No Organization found.");
}
return new UpdateCollectionRequest({
name: model.canEditName(org)
? await this.encryptService.encryptString(model.name, key)
: null,
externalId: model.externalId,
users,
groups,
});
}
return new CreateCollectionRequest({
name: await this.encryptService.encryptString(model.name, key), name: await this.encryptService.encryptString(model.name, key),
externalId: model.externalId, externalId: model.externalId,
users, users,
groups, groups,
}); });
return collectionRequest;
} }
} }

View File

@@ -216,7 +216,7 @@ export class DefaultCollectionService implements CollectionService {
getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] { getAllNested(collections: CollectionView[]): TreeNode<CollectionView>[] {
const nodes: TreeNode<CollectionView>[] = []; const nodes: TreeNode<CollectionView>[] = [];
collections.forEach((c) => { collections.forEach((c) => {
const collectionCopy = Object.assign(new CollectionView({ ...c }), c); const collectionCopy = Object.assign(new CollectionView({ ...c, name: c.name }), c);
const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter); ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter);

View File

@@ -3,8 +3,9 @@
import { import {
CollectionAccessDetailsResponse, CollectionAccessDetailsResponse,
CollectionDetailsResponse, CollectionDetailsResponse,
CollectionRequest,
CollectionResponse, CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common"; } from "@bitwarden/admin-console/common";
import { OrganizationConnectionType } from "../admin-console/enums"; import { OrganizationConnectionType } from "../admin-console/enums";
@@ -270,12 +271,12 @@ export abstract class ApiService {
): Promise<ListResponse<CollectionAccessDetailsResponse>>; ): Promise<ListResponse<CollectionAccessDetailsResponse>>;
abstract postCollection( abstract postCollection(
organizationId: string, organizationId: string,
request: CollectionRequest, request: CreateCollectionRequest,
): Promise<CollectionDetailsResponse>; ): Promise<CollectionDetailsResponse>;
abstract putCollection( abstract putCollection(
organizationId: string, organizationId: string,
id: string, id: string,
request: CollectionRequest, request: UpdateCollectionRequest,
): Promise<CollectionDetailsResponse>; ): Promise<CollectionDetailsResponse>;
abstract deleteCollection(organizationId: string, id: string): Promise<any>; abstract deleteCollection(organizationId: string, id: string): Promise<any>;
abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>; abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>;

View File

@@ -7,8 +7,9 @@ import { firstValueFrom } from "rxjs";
import { import {
CollectionAccessDetailsResponse, CollectionAccessDetailsResponse,
CollectionDetailsResponse, CollectionDetailsResponse,
CollectionRequest,
CollectionResponse, CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common"; } from "@bitwarden/admin-console/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
@@ -727,7 +728,7 @@ export class ApiService implements ApiServiceAbstraction {
async postCollection( async postCollection(
organizationId: string, organizationId: string,
request: CollectionRequest, request: CreateCollectionRequest,
): Promise<CollectionDetailsResponse> { ): Promise<CollectionDetailsResponse> {
const r = await this.send( const r = await this.send(
"POST", "POST",
@@ -742,7 +743,7 @@ export class ApiService implements ApiServiceAbstraction {
async putCollection( async putCollection(
organizationId: string, organizationId: string,
id: string, id: string,
request: CollectionRequest, request: UpdateCollectionRequest,
): Promise<CollectionDetailsResponse> { ): Promise<CollectionDetailsResponse> {
const r = await this.send( const r = await this.send(
"PUT", "PUT",

View File

@@ -33,23 +33,24 @@ const createMockCollection = (
readOnly = false, readOnly = false,
canEdit = true, canEdit = true,
): CollectionView => { ): CollectionView => {
return { const cv = new CollectionView({
id: id as CollectionId,
name, name,
organizationId: organizationId as OrganizationId, organizationId: organizationId as OrganizationId,
externalId: "", id: id as CollectionId,
readOnly, });
hidePasswords: false, cv.readOnly = readOnly;
manage: true, cv.manage = true;
assigned: true, cv.type = CollectionTypes.DefaultUserCollection;
type: CollectionTypes.DefaultUserCollection, cv.externalId = "";
isDefaultCollection: true, cv.hidePasswords = false;
canEditItems: jest.fn().mockReturnValue(canEdit), cv.assigned = true;
canEdit: jest.fn(), cv.canEditName = jest.fn().mockReturnValue(true);
canDelete: jest.fn(), cv.canEditItems = jest.fn().mockReturnValue(canEdit);
canViewCollectionInfo: jest.fn(), cv.canEdit = jest.fn();
encrypt: jest.fn(), cv.canDelete = jest.fn();
}; cv.canViewCollectionInfo = jest.fn();
return cv;
}; };
describe("ItemDetailsSectionComponent", () => { describe("ItemDetailsSectionComponent", () => {