diff --git a/libs/admin-console/src/common/collections/abstractions/collection.service.ts b/libs/admin-console/src/common/collections/abstractions/collection.service.ts index 6b590bcdda..15d4f31582 100644 --- a/libs/admin-console/src/common/collections/abstractions/collection.service.ts +++ b/libs/admin-console/src/common/collections/abstractions/collection.service.ts @@ -1,33 +1,41 @@ import { Observable } from "rxjs"; -import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CollectionData, Collection, CollectionView } from "../models"; export abstract class CollectionService { - encryptedCollections$: Observable; - decryptedCollections$: Observable; - - clearActiveUserCache: () => Promise; - encrypt: (model: CollectionView) => Promise; - decryptedCollectionViews$: (ids: CollectionId[]) => Observable; + encryptedCollections$: (userId$: Observable) => Observable; + decryptedCollections$: (userId$: Observable) => Observable; + upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise; + replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; /** - * @deprecated This method will soon be made private - * See PM-12375 + * Clear decrypted state without affecting encrypted state. + * Used for locking the vault. + */ + clearDecryptedState: (userId: UserId) => Promise; + /** + * Clear decrypted and encrypted state. + * Used for logging out. + */ + clear: (userId: string) => Promise; + delete: (id: string | string[], userId: UserId) => Promise; + encrypt: (model: CollectionView) => Promise; + /** + * @deprecated This method will soon be made private, use `decryptedCollections$` instead. */ decryptMany: ( collections: Collection[], orgKeys?: Record, ) => Promise; - get: (id: string) => Promise; - getAll: () => Promise; - getAllDecrypted: () => Promise; - getAllNested: (collections?: CollectionView[]) => Promise[]>; - getNested: (id: string) => Promise>; - upsert: (collection: CollectionData | CollectionData[]) => Promise; - replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; - clear: (userId?: string) => Promise; - delete: (id: string | string[]) => Promise; + /** + * Transforms the input CollectionViews into TreeNodes + */ + getAllNested: (collections: CollectionView[]) => TreeNode[]; + /** + * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id + */ + getNested: (collections: CollectionView[], id: string) => TreeNode; } diff --git a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts deleted file mode 100644 index 4b5828ccf3..0000000000 --- a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Observable } from "rxjs"; - -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; -import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; - -import { CollectionData, Collection, CollectionView } from "../models"; - -export abstract class vNextCollectionService { - encryptedCollections$: (userId$: Observable) => Observable; - decryptedCollections$: (userId$: Observable) => Observable; - upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise; - replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; - /** - * Clear decrypted state without affecting encrypted state. - * Used for locking the vault. - */ - clearDecryptedState: (userId: UserId) => Promise; - /** - * Clear decrypted and encrypted state. - * Used for logging out. - */ - clear: (userId: string) => Promise; - delete: (id: string | string[], userId: UserId) => Promise; - encrypt: (model: CollectionView) => Promise; - /** - * @deprecated This method will soon be made private, use `decryptedCollections$` instead. - */ - decryptMany: ( - collections: Collection[], - orgKeys?: Record, - ) => Promise; - /** - * Transforms the input CollectionViews into TreeNodes - */ - getAllNested: (collections: CollectionView[]) => TreeNode[]; - /** - * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id - */ - getNested: (collections: CollectionView[], id: string) => TreeNode; -} diff --git a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts b/libs/admin-console/src/common/collections/services/collection.state.ts similarity index 90% rename from libs/admin-console/src/common/collections/services/vnext-collection.state.ts rename to libs/admin-console/src/common/collections/services/collection.state.ts index 533308f3cc..308cb6dc60 100644 --- a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts +++ b/libs/admin-console/src/common/collections/services/collection.state.ts @@ -8,7 +8,7 @@ import { import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; -import { vNextCollectionService } from "../abstractions/vnext-collection.service"; +import { CollectionService } from "../abstractions/collection.service"; import { Collection, CollectionData, CollectionView } from "../models"; export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( @@ -23,7 +23,7 @@ export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record, Record], CollectionView[], - { collectionService: vNextCollectionService } + { collectionService: CollectionService } >(COLLECTION_DATA, "decryptedCollections", { deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), derive: async ([collections, orgKeys], { collectionService }) => { diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts index a230a20b2e..82c39da960 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts @@ -1,10 +1,11 @@ -import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of, ReplaySubject } from "rxjs"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { FakeStateProvider, @@ -18,122 +19,307 @@ import { KeyService } from "@bitwarden/key-management"; import { CollectionData } from "../models"; -import { - DefaultCollectionService, - ENCRYPTED_COLLECTION_DATA_KEY, -} from "./default-collection.service"; +import { ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state"; +import { DefaultCollectionService } from "./default-collection.service"; describe("DefaultCollectionService", () => { + let keyService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let stateProvider: FakeStateProvider; + + let userId: UserId; + + let cryptoKeys: ReplaySubject | null>; + + let collectionService: DefaultCollectionService; + + beforeEach(() => { + userId = Utils.newGuid() as UserId; + + keyService = mock(); + encryptService = mock(); + i18nService = mock(); + stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + + cryptoKeys = new ReplaySubject(1); + keyService.orgKeys$.mockReturnValue(cryptoKeys); + + // Set up mock decryption + encryptService.decryptToUtf8 + .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) + .mockImplementation((encString, key) => + Promise.resolve(encString.data.replace("ENC_", "DEC_")), + ); + + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + + // Arrange i18nService so that sorting algorithm doesn't throw + i18nService.collator = null; + + collectionService = new DefaultCollectionService( + keyService, + encryptService, + i18nService, + stateProvider, + ); + }); + afterEach(() => { delete (window as any).bitwardenContainerService; }); describe("decryptedCollections$", () => { it("emits decrypted collections from state", async () => { - // Arrange test collections + // Arrange test data const org1 = Utils.newGuid() as OrganizationId; - const org2 = Utils.newGuid() as OrganizationId; - + const orgKey1 = makeSymmetricCryptoKey(64, 1); const collection1 = collectionDataFactory(org1); + + const org2 = Utils.newGuid() as OrganizationId; + const orgKey2 = makeSymmetricCryptoKey(64, 2); const collection2 = collectionDataFactory(org2); - // Arrange state provider - const fakeStateProvider = mockStateProvider(); - await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, { - [collection1.id]: collection1, - [collection2.id]: collection2, + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + cryptoKeys.next({ + [org1]: orgKey1, + [org2]: orgKey2, }); - // Arrange cryptoService - orgKeys and mock decryption - const cryptoService = mockCryptoService(); - cryptoService.orgKeys$.mockReturnValue( - of({ - [org1]: makeSymmetricCryptoKey(), - [org2]: makeSymmetricCryptoKey(), - }), - ); + const result = await firstValueFrom(collectionService.decryptedCollections$(of(userId))); - const collectionService = new DefaultCollectionService( - cryptoService, - mock(), - mockI18nService(), - fakeStateProvider, - ); - - const result = await firstValueFrom(collectionService.decryptedCollections$); + // Assert emitted values expect(result.length).toBe(2); - expect(result[0]).toMatchObject({ - id: collection1.id, - name: "DECRYPTED_STRING", - }); - expect(result[1]).toMatchObject({ - id: collection2.id, - name: "DECRYPTED_STRING", - }); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: "DEC_NAME_" + collection1.id, + }, + { + id: collection2.id, + name: "DEC_NAME_" + collection2.id, + }, + ]); + + // Assert that the correct org keys were used for each encrypted string + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection1.name)), + orgKey1, + ); + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection2.name)), + orgKey2, + ); }); it("handles null collection state", async () => { - // Arrange test collections - const org1 = Utils.newGuid() as OrganizationId; - const org2 = Utils.newGuid() as OrganizationId; + // Arrange dependencies + await setEncryptedState(null); + cryptoKeys.next({}); - // Arrange state provider - const fakeStateProvider = mockStateProvider(); - await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null); - - // Arrange cryptoService - orgKeys and mock decryption - const cryptoService = mockCryptoService(); - cryptoService.orgKeys$.mockReturnValue( - of({ - [org1]: makeSymmetricCryptoKey(), - [org2]: makeSymmetricCryptoKey(), - }), + const encryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), ); - const collectionService = new DefaultCollectionService( - cryptoService, - mock(), - mockI18nService(), - fakeStateProvider, - ); - - const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$); - expect(decryptedCollections.length).toBe(0); - - const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$); expect(encryptedCollections.length).toBe(0); }); }); + + describe("encryptedCollections$", () => { + it("emits encrypted collections from state", async () => { + // Arrange test data + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + + expect(result.length).toBe(2); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + ]); + }); + + it("handles null collection state", async () => { + await setEncryptedState(null); + + const decryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(decryptedCollections.length).toBe(0); + }); + }); + + describe("upsert", () => { + it("upserts to existing collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + await setEncryptedState([collection1, collection2]); + + const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, + }); + const newCollection3 = collectionDataFactory(); + + await collectionService.upsert([updatedCollection1, newCollection3], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(3); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + + it("upserts to a null state", async () => { + const collection1 = collectionDataFactory(); + + await setEncryptedState(null); + + await collectionService.upsert(collection1, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(1); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + ]); + }); + }); + + describe("replace", () => { + it("replaces all collections", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + const newCollection3 = collectionDataFactory(); + await collectionService.replace( + { + [newCollection3.id]: newCollection3, + }, + userId, + ); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(1); + expect(result).toIncludeAllPartialMembers([ + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + }); + + it("clearDecryptedState", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + await collectionService.clearDecryptedState(userId); + + // Encrypted state remains + const encryptedState = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(encryptedState.length).toEqual(2); + + // Decrypted state is cleared + const decryptedState = await firstValueFrom( + collectionService.decryptedCollections$(of(userId)), + ); + expect(decryptedState.length).toEqual(0); + }); + + it("clear", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + cryptoKeys.next({}); + + await collectionService.clear(userId); + + // Encrypted state is cleared + const encryptedState = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(encryptedState.length).toEqual(0); + + // Decrypted state is cleared + const decryptedState = await firstValueFrom( + collectionService.decryptedCollections$(of(userId)), + ); + expect(decryptedState.length).toEqual(0); + }); + + describe("delete", () => { + it("deletes a collection", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + await setEncryptedState([collection1, collection2]); + + await collectionService.delete(collection1.id, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(1); + expect(result[0]).toMatchObject({ id: collection2.id }); + }); + + it("deletes several collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + const collection3 = collectionDataFactory(); + await setEncryptedState([collection1, collection2, collection3]); + + await collectionService.delete([collection1.id, collection3.id], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(1); + expect(result[0]).toMatchObject({ id: collection2.id }); + }); + + it("handles null collections", async () => { + const collection1 = collectionDataFactory(); + await setEncryptedState(null); + + await collectionService.delete(collection1.id, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(0); + }); + }); + + const setEncryptedState = (collectionData: CollectionData[] | null) => + stateProvider.setUserState( + ENCRYPTED_COLLECTION_DATA_KEY, + collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), + userId, + ); }); -const mockI18nService = () => { - const i18nService = mock(); - i18nService.collator = null; // this is a mock only, avoid use of this object - return i18nService; -}; - -const mockStateProvider = () => { - const userId = Utils.newGuid() as UserId; - return new FakeStateProvider(mockAccountServiceWith(userId)); -}; - -const mockCryptoService = () => { - const keyService = mock(); - const encryptService = mock(); - encryptService.decryptToUtf8 - .calledWith(expect.any(EncString), expect.anything()) - .mockResolvedValue("DECRYPTED_STRING"); - - (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - - return keyService; -}; - -const collectionDataFactory = (orgId: OrganizationId) => { +const collectionDataFactory = (orgId?: OrganizationId) => { const collection = new CollectionData({} as any); collection.id = Utils.newGuid() as CollectionId; - collection.organizationId = orgId; - collection.name = makeEncString("ENC_STRING").encryptedString; + collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); + collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString; return collection; }; diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.ts b/libs/admin-console/src/common/collections/services/default-collection.service.ts index c83bb6fb16..0fd1a780d5 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.ts @@ -1,74 +1,33 @@ import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; -import { Jsonify } from "type-fest"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { - ActiveUserState, - StateProvider, - COLLECTION_DATA, - DeriveDefinition, - DerivedState, - UserKeyDefinition, -} from "@bitwarden/common/platform/state"; +import { StateProvider, DerivedState } from "@bitwarden/common/platform/state"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { KeyService } from "@bitwarden/key-management"; -import { CollectionService } from "../abstractions"; +import { CollectionService } from "../abstractions/collection.service"; import { Collection, CollectionData, CollectionView } from "../models"; -export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( - COLLECTION_DATA, - "collections", - { - deserializer: (jsonData: Jsonify) => CollectionData.fromJSON(jsonData), - clearOn: ["logout"], - }, -); - -const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition< - [Record, Record], - CollectionView[], - { collectionService: DefaultCollectionService } ->(COLLECTION_DATA, "decryptedCollections", { - deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), - derive: async ([collections, orgKeys], { collectionService }) => { - if (collections == null) { - return []; - } - - const data = Object.values(collections).map((c) => new Collection(c)); - return await collectionService.decryptMany(data, orgKeys); - }, -}); +import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state"; const NestingDelimiter = "/"; export class DefaultCollectionService implements CollectionService { - private encryptedCollectionDataState: ActiveUserState>; - encryptedCollections$: Observable; - private decryptedCollectionDataState: DerivedState; - decryptedCollections$: Observable; - - decryptedCollectionViews$(ids: CollectionId[]): Observable { - return this.decryptedCollections$.pipe( - map((collections) => collections.filter((c) => ids.includes(c.id as CollectionId))), - ); - } - constructor( private keyService: KeyService, private encryptService: EncryptService, private i18nService: I18nService, protected stateProvider: StateProvider, - ) { - this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY); + ) {} - this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe( + encryptedCollections$(userId$: Observable) { + return userId$.pipe( + switchMap((userId) => this.encryptedState(userId).state$), map((collections) => { if (collections == null) { return []; @@ -77,24 +36,67 @@ export class DefaultCollectionService implements CollectionService { return Object.values(collections).map((c) => new Collection(c)); }), ); - - const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe( - switchMap(([userId, collectionData]) => - combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), - ), - ); - - this.decryptedCollectionDataState = this.stateProvider.getDerived( - encryptedCollectionsWithKeys, - DECRYPTED_COLLECTION_DATA_KEY, - { collectionService: this }, - ); - - this.decryptedCollections$ = this.decryptedCollectionDataState.state$; } - async clearActiveUserCache(): Promise { - await this.decryptedCollectionDataState.forceValue(null); + decryptedCollections$(userId$: Observable) { + return userId$.pipe( + switchMap((userId) => this.decryptedState(userId).state$), + map((collections) => collections ?? []), + ); + } + + async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise { + if (toUpdate == null) { + return; + } + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + if (Array.isArray(toUpdate)) { + toUpdate.forEach((c) => { + collections[c.id] = c; + }); + } else { + collections[toUpdate.id] = toUpdate; + } + return collections; + }); + } + + async replace(collections: Record, userId: UserId): Promise { + await this.encryptedState(userId).update(() => collections); + } + + async clearDecryptedState(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + + await this.decryptedState(userId).forceValue(null); + } + + async clear(userId: UserId): Promise { + await this.encryptedState(userId).update(() => null); + // This will propagate from the encrypted state update, but by doing it explicitly + // the promise doesn't resolve until the update is complete. + await this.decryptedState(userId).forceValue(null); + } + + async delete(id: CollectionId | CollectionId[], userId: UserId): Promise { + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + if (typeof id === "string") { + delete collections[id]; + } else { + (id as CollectionId[]).forEach((i) => { + delete collections[i]; + }); + } + return collections; + }); } async encrypt(model: CollectionView): Promise { @@ -139,26 +141,7 @@ export class DefaultCollectionService implements CollectionService { return decCollections.sort(Utils.getSortFunction(this.i18nService, "name")); } - async get(id: string): Promise { - return ( - (await firstValueFrom( - this.encryptedCollections$.pipe(map((cs) => cs.find((c) => c.id === id))), - )) ?? null - ); - } - - async getAll(): Promise { - return await firstValueFrom(this.encryptedCollections$); - } - - async getAllDecrypted(): Promise { - return await firstValueFrom(this.decryptedCollections$); - } - - async getAllNested(collections: CollectionView[] = null): Promise[]> { - if (collections == null) { - collections = await this.getAllDecrypted(); - } + getAllNested(collections: CollectionView[]): TreeNode[] { const nodes: TreeNode[] = []; collections.forEach((c) => { const collectionCopy = new CollectionView(); @@ -174,58 +157,37 @@ export class DefaultCollectionService implements CollectionService { * @deprecated August 30 2022: Moved to new Vault Filter Service * Remove when Desktop and Browser are updated */ - async getNested(id: string): Promise> { - const collections = await this.getAllNested(); - return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode; + getNested(collections: CollectionView[], id: string): TreeNode { + const nestedCollections = this.getAllNested(collections); + return ServiceUtils.getTreeNodeObjectFromList( + nestedCollections, + id, + ) as TreeNode; } - async upsert(toUpdate: CollectionData | CollectionData[]): Promise { - if (toUpdate == null) { - return; - } - await this.encryptedCollectionDataState.update((collections) => { - if (collections == null) { - collections = {}; - } - if (Array.isArray(toUpdate)) { - toUpdate.forEach((c) => { - collections[c.id] = c; - }); - } else { - collections[toUpdate.id] = toUpdate; - } - return collections; - }); + /** + * @returns a SingleUserState for encrypted collection data. + */ + private encryptedState(userId: UserId) { + return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY); } - async replace(collections: Record, userId: UserId): Promise { - await this.stateProvider - .getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY) - .update(() => collections); - } + /** + * @returns a SingleUserState for decrypted collection data. + */ + private decryptedState(userId: UserId): DerivedState { + const encryptedCollectionsWithKeys = this.encryptedState(userId).combinedState$.pipe( + switchMap(([userId, collectionData]) => + combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), + ), + ); - async clear(userId?: UserId): Promise { - if (userId == null) { - await this.encryptedCollectionDataState.update(() => null); - await this.decryptedCollectionDataState.forceValue(null); - } else { - await this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY).update(() => null); - } - } - - async delete(id: CollectionId | CollectionId[]): Promise { - await this.encryptedCollectionDataState.update((collections) => { - if (collections == null) { - collections = {}; - } - if (typeof id === "string") { - delete collections[id]; - } else { - (id as CollectionId[]).forEach((i) => { - delete collections[i]; - }); - } - return collections; - }); + return this.stateProvider.getDerived( + encryptedCollectionsWithKeys, + DECRYPTED_COLLECTION_DATA_KEY, + { + collectionService: this, + }, + ); } } diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts deleted file mode 100644 index 4ca60cba77..0000000000 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, of, ReplaySubject } from "rxjs"; - -import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { ContainerService } from "@bitwarden/common/platform/services/container.service"; -import { - FakeStateProvider, - makeEncString, - makeSymmetricCryptoKey, - mockAccountServiceWith, -} from "@bitwarden/common/spec"; -import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; -import { KeyService } from "@bitwarden/key-management"; - -import { CollectionData } from "../models"; - -import { DefaultvNextCollectionService } from "./default-vnext-collection.service"; -import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state"; - -describe("DefaultvNextCollectionService", () => { - let keyService: MockProxy; - let encryptService: MockProxy; - let i18nService: MockProxy; - let stateProvider: FakeStateProvider; - - let userId: UserId; - - let cryptoKeys: ReplaySubject | null>; - - let collectionService: DefaultvNextCollectionService; - - beforeEach(() => { - userId = Utils.newGuid() as UserId; - - keyService = mock(); - encryptService = mock(); - i18nService = mock(); - stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); - - cryptoKeys = new ReplaySubject(1); - keyService.orgKeys$.mockReturnValue(cryptoKeys); - - // Set up mock decryption - encryptService.decryptToUtf8 - .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) - .mockImplementation((encString, key) => - Promise.resolve(encString.data.replace("ENC_", "DEC_")), - ); - - (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - - // Arrange i18nService so that sorting algorithm doesn't throw - i18nService.collator = null; - - collectionService = new DefaultvNextCollectionService( - keyService, - encryptService, - i18nService, - stateProvider, - ); - }); - - afterEach(() => { - delete (window as any).bitwardenContainerService; - }); - - describe("decryptedCollections$", () => { - it("emits decrypted collections from state", async () => { - // Arrange test data - const org1 = Utils.newGuid() as OrganizationId; - const orgKey1 = makeSymmetricCryptoKey(64, 1); - const collection1 = collectionDataFactory(org1); - - const org2 = Utils.newGuid() as OrganizationId; - const orgKey2 = makeSymmetricCryptoKey(64, 2); - const collection2 = collectionDataFactory(org2); - - // Arrange dependencies - await setEncryptedState([collection1, collection2]); - cryptoKeys.next({ - [org1]: orgKey1, - [org2]: orgKey2, - }); - - const result = await firstValueFrom(collectionService.decryptedCollections$(of(userId))); - - // Assert emitted values - expect(result.length).toBe(2); - expect(result).toIncludeAllPartialMembers([ - { - id: collection1.id, - name: "DEC_NAME_" + collection1.id, - }, - { - id: collection2.id, - name: "DEC_NAME_" + collection2.id, - }, - ]); - - // Assert that the correct org keys were used for each encrypted string - expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( - expect.objectContaining(new EncString(collection1.name)), - orgKey1, - ); - expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( - expect.objectContaining(new EncString(collection2.name)), - orgKey2, - ); - }); - - it("handles null collection state", async () => { - // Arrange dependencies - await setEncryptedState(null); - cryptoKeys.next({}); - - const encryptedCollections = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), - ); - - expect(encryptedCollections.length).toBe(0); - }); - }); - - describe("encryptedCollections$", () => { - it("emits encrypted collections from state", async () => { - // Arrange test data - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - - // Arrange dependencies - await setEncryptedState([collection1, collection2]); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - - expect(result.length).toBe(2); - expect(result).toIncludeAllPartialMembers([ - { - id: collection1.id, - name: makeEncString("ENC_NAME_" + collection1.id), - }, - { - id: collection2.id, - name: makeEncString("ENC_NAME_" + collection2.id), - }, - ]); - }); - - it("handles null collection state", async () => { - await setEncryptedState(null); - - const decryptedCollections = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), - ); - expect(decryptedCollections.length).toBe(0); - }); - }); - - describe("upsert", () => { - it("upserts to existing collections", async () => { - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - - await setEncryptedState([collection1, collection2]); - - const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { - name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, - }); - const newCollection3 = collectionDataFactory(); - - await collectionService.upsert([updatedCollection1, newCollection3], userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - expect(result.length).toBe(3); - expect(result).toIncludeAllPartialMembers([ - { - id: collection1.id, - name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), - }, - { - id: collection2.id, - name: makeEncString("ENC_NAME_" + collection2.id), - }, - { - id: newCollection3.id, - name: makeEncString("ENC_NAME_" + newCollection3.id), - }, - ]); - }); - - it("upserts to a null state", async () => { - const collection1 = collectionDataFactory(); - - await setEncryptedState(null); - - await collectionService.upsert(collection1, userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - expect(result.length).toBe(1); - expect(result).toIncludeAllPartialMembers([ - { - id: collection1.id, - name: makeEncString("ENC_NAME_" + collection1.id), - }, - ]); - }); - }); - - describe("replace", () => { - it("replaces all collections", async () => { - await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); - - const newCollection3 = collectionDataFactory(); - await collectionService.replace( - { - [newCollection3.id]: newCollection3, - }, - userId, - ); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - expect(result.length).toBe(1); - expect(result).toIncludeAllPartialMembers([ - { - id: newCollection3.id, - name: makeEncString("ENC_NAME_" + newCollection3.id), - }, - ]); - }); - }); - - it("clearDecryptedState", async () => { - await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); - - await collectionService.clearDecryptedState(userId); - - // Encrypted state remains - const encryptedState = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), - ); - expect(encryptedState.length).toEqual(2); - - // Decrypted state is cleared - const decryptedState = await firstValueFrom( - collectionService.decryptedCollections$(of(userId)), - ); - expect(decryptedState.length).toEqual(0); - }); - - it("clear", async () => { - await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); - cryptoKeys.next({}); - - await collectionService.clear(userId); - - // Encrypted state is cleared - const encryptedState = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), - ); - expect(encryptedState.length).toEqual(0); - - // Decrypted state is cleared - const decryptedState = await firstValueFrom( - collectionService.decryptedCollections$(of(userId)), - ); - expect(decryptedState.length).toEqual(0); - }); - - describe("delete", () => { - it("deletes a collection", async () => { - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - await setEncryptedState([collection1, collection2]); - - await collectionService.delete(collection1.id, userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - expect(result.length).toEqual(1); - expect(result[0]).toMatchObject({ id: collection2.id }); - }); - - it("deletes several collections", async () => { - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - const collection3 = collectionDataFactory(); - await setEncryptedState([collection1, collection2, collection3]); - - await collectionService.delete([collection1.id, collection3.id], userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - expect(result.length).toEqual(1); - expect(result[0]).toMatchObject({ id: collection2.id }); - }); - - it("handles null collections", async () => { - const collection1 = collectionDataFactory(); - await setEncryptedState(null); - - await collectionService.delete(collection1.id, userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); - expect(result.length).toEqual(0); - }); - }); - - const setEncryptedState = (collectionData: CollectionData[] | null) => - stateProvider.setUserState( - ENCRYPTED_COLLECTION_DATA_KEY, - collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), - userId, - ); -}); - -const collectionDataFactory = (orgId?: OrganizationId) => { - const collection = new CollectionData({} as any); - collection.id = Utils.newGuid() as CollectionId; - collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); - collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString; - - return collection; -}; diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts deleted file mode 100644 index 8ca1ab7fcf..0000000000 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; - -import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { StateProvider, DerivedState } from "@bitwarden/common/platform/state"; -import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; -import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; -import { KeyService } from "@bitwarden/key-management"; - -import { vNextCollectionService } from "../abstractions/vnext-collection.service"; -import { Collection, CollectionData, CollectionView } from "../models"; - -import { - DECRYPTED_COLLECTION_DATA_KEY, - ENCRYPTED_COLLECTION_DATA_KEY, -} from "./vnext-collection.state"; - -const NestingDelimiter = "/"; - -export class DefaultvNextCollectionService implements vNextCollectionService { - constructor( - private keyService: KeyService, - private encryptService: EncryptService, - private i18nService: I18nService, - protected stateProvider: StateProvider, - ) {} - - encryptedCollections$(userId$: Observable) { - return userId$.pipe( - switchMap((userId) => this.encryptedState(userId).state$), - map((collections) => { - if (collections == null) { - return []; - } - - return Object.values(collections).map((c) => new Collection(c)); - }), - ); - } - - decryptedCollections$(userId$: Observable) { - return userId$.pipe( - switchMap((userId) => this.decryptedState(userId).state$), - map((collections) => collections ?? []), - ); - } - - async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise { - if (toUpdate == null) { - return; - } - await this.encryptedState(userId).update((collections) => { - if (collections == null) { - collections = {}; - } - if (Array.isArray(toUpdate)) { - toUpdate.forEach((c) => { - collections[c.id] = c; - }); - } else { - collections[toUpdate.id] = toUpdate; - } - return collections; - }); - } - - async replace(collections: Record, userId: UserId): Promise { - await this.encryptedState(userId).update(() => collections); - } - - async clearDecryptedState(userId: UserId): Promise { - if (userId == null) { - throw new Error("User ID is required."); - } - - await this.decryptedState(userId).forceValue(null); - } - - async clear(userId: UserId): Promise { - await this.encryptedState(userId).update(() => null); - // This will propagate from the encrypted state update, but by doing it explicitly - // the promise doesn't resolve until the update is complete. - await this.decryptedState(userId).forceValue(null); - } - - async delete(id: CollectionId | CollectionId[], userId: UserId): Promise { - await this.encryptedState(userId).update((collections) => { - if (collections == null) { - collections = {}; - } - if (typeof id === "string") { - delete collections[id]; - } else { - (id as CollectionId[]).forEach((i) => { - delete collections[i]; - }); - } - return collections; - }); - } - - async encrypt(model: CollectionView): Promise { - if (model.organizationId == null) { - throw new Error("Collection has no organization id."); - } - const key = await this.keyService.getOrgKey(model.organizationId); - if (key == null) { - throw new Error("No key for this collection's organization."); - } - const collection = new Collection(); - collection.id = model.id; - collection.organizationId = model.organizationId; - collection.readOnly = model.readOnly; - collection.externalId = model.externalId; - collection.name = await this.encryptService.encrypt(model.name, key); - return collection; - } - - // TODO: this should be private and orgKeys should be required. - // See https://bitwarden.atlassian.net/browse/PM-12375 - async decryptMany( - collections: Collection[], - orgKeys?: Record, - ): Promise { - if (collections == null || collections.length === 0) { - return []; - } - const decCollections: CollectionView[] = []; - - orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$); - - const promises: Promise[] = []; - collections.forEach((collection) => { - promises.push( - collection - .decrypt(orgKeys[collection.organizationId as OrganizationId]) - .then((c) => decCollections.push(c)), - ); - }); - await Promise.all(promises); - return decCollections.sort(Utils.getSortFunction(this.i18nService, "name")); - } - - getAllNested(collections: CollectionView[]): TreeNode[] { - const nodes: TreeNode[] = []; - collections.forEach((c) => { - const collectionCopy = new CollectionView(); - collectionCopy.id = c.id; - collectionCopy.organizationId = c.organizationId; - const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); - }); - return nodes; - } - - /** - * @deprecated August 30 2022: Moved to new Vault Filter Service - * Remove when Desktop and Browser are updated - */ - getNested(collections: CollectionView[], id: string): TreeNode { - const nestedCollections = this.getAllNested(collections); - return ServiceUtils.getTreeNodeObjectFromList( - nestedCollections, - id, - ) as TreeNode; - } - - /** - * @returns a SingleUserState for encrypted collection data. - */ - private encryptedState(userId: UserId) { - return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY); - } - - /** - * @returns a SingleUserState for decrypted collection data. - */ - private decryptedState(userId: UserId): DerivedState { - const encryptedCollectionsWithKeys = this.encryptedState(userId).combinedState$.pipe( - switchMap(([userId, collectionData]) => - combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), - ), - ); - - return this.stateProvider.getDerived( - encryptedCollectionsWithKeys, - DECRYPTED_COLLECTION_DATA_KEY, - { - collectionService: this, - }, - ); - } -}