1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

PM-5274 Migrate Collection Service State (#7732)

* update collection service to use new state provider framework, remove stateservice from collection service, update collections state provider with migrate file and unit test
This commit is contained in:
Jason Ng
2024-02-14 17:03:03 -05:00
committed by GitHub
parent d8b74b78da
commit 3edf098aaf
23 changed files with 426 additions and 170 deletions

View File

@@ -454,7 +454,7 @@ export default class MainBackground {
this.collectionService = new CollectionService(
this.cryptoService,
this.i18nService,
this.stateService,
this.stateProvider,
);
this.syncNotifierService = new SyncNotifierService();
this.organizationService = new BrowserOrganizationService(

View File

@@ -14,10 +14,8 @@ import {
i18nServiceFactory,
I18nServiceInitOptions,
} from "../../../platform/background/service-factories/i18n-service.factory";
import {
stateServiceFactory as stateServiceFactory,
StateServiceInitOptions,
} from "../../../platform/background/service-factories/state-service.factory";
import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory";
import { StateServiceInitOptions } from "../../../platform/background/service-factories/state-service.factory";
type CollectionServiceFactoryOptions = FactoryOptions;
@@ -38,7 +36,7 @@ export function collectionServiceFactory(
new CollectionService(
await cryptoServiceFactory(cache, opts),
await i18nServiceFactory(cache, opts),
await stateServiceFactory(cache, opts),
await stateProviderFactory(cache, opts),
),
);
}

View File

@@ -87,6 +87,7 @@ import {
} from "@bitwarden/common/tools/password-strength";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service";
import { SendService } from "@bitwarden/common/tools/send/services/send.service";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/services/collection.service";
@@ -358,7 +359,7 @@ export class Main {
this.collectionService = new CollectionService(
this.cryptoService,
this.i18nService,
this.stateService,
this.stateProvider,
);
this.providerService = new ProviderService(this.stateService);
@@ -616,7 +617,7 @@ export class Main {
this.settingsService.clear(userId),
this.cipherService.clear(userId),
this.folderService.clear(userId),
this.collectionService.clear(userId),
this.collectionService.clear(userId as UserId),
this.policyService.clear(userId),
this.passwordGenerationService.clear(),
]);

View File

@@ -19,7 +19,6 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r
import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service";
import { SendData } from "@bitwarden/common/tools/send/models/data/send.data";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Account } from "./account";
import { GlobalState } from "./global-state";
@@ -69,21 +68,6 @@ export class StateService extends BaseStateService<GlobalState, Account> {
return await super.setEncryptedCiphers(value, options);
}
async getEncryptedCollections(
options?: StorageOptions,
): Promise<{ [id: string]: CollectionData }> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.getEncryptedCollections(options);
}
async setEncryptedCollections(
value: { [id: string]: CollectionData },
options?: StorageOptions,
): Promise<void> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.setEncryptedCollections(value, options);
}
async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.getEncryptedSends(options);

View File

@@ -28,7 +28,7 @@
aria-hidden="true"
></i>
<span>{{ title }}</span>
<ng-container *ngIf="collection !== undefined && (canEditCollection || canDeleteCollection)">
<ng-container *ngIf="collection != null && (canEditCollection || canDeleteCollection)">
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"

View File

@@ -125,7 +125,7 @@ export class VaultHeaderComponent {
get canEditCollection(): boolean {
// Only edit collections if not editing "Unassigned"
if (this.collection === undefined) {
if (this.collection == null) {
return false;
}

View File

@@ -33,7 +33,7 @@
aria-hidden="true"
></i>
<span>{{ title }}</span>
<ng-container *ngIf="collection !== undefined && (canEditCollection || canDeleteCollection)">
<ng-container *ngIf="collection != null && (canEditCollection || canDeleteCollection)">
<button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"

View File

@@ -418,7 +418,7 @@ import { ModalService } from "./modal.service";
{
provide: CollectionServiceAbstraction,
useClass: CollectionService,
deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction],
deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateProvider],
},
{
provide: EnvironmentServiceAbstraction,

View File

@@ -19,10 +19,8 @@ import { UserId } from "../../types/guid";
import { DeviceKey, MasterKey, UserKey } from "../../types/key";
import { UriMatchType } from "../../vault/enums";
import { CipherData } from "../../vault/models/data/cipher.data";
import { CollectionData } from "../../vault/models/data/collection.data";
import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view";
import { CollectionView } from "../../vault/models/view/collection.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { KdfType, ThemeType } from "../enums";
import { ServerConfigData } from "../models/data/server-config.data";
@@ -200,8 +198,6 @@ export abstract class StateService<T extends Account = Account> {
setBiometricPromptCancelled: (value: boolean, options?: StorageOptions) => Promise<void>;
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;
setDecryptedCollections: (value: CollectionView[], options?: StorageOptions) => Promise<void>;
getDecryptedPasswordGenerationHistory: (
options?: StorageOptions,
) => Promise<GeneratedPasswordHistory[]>;
@@ -313,11 +309,6 @@ export abstract class StateService<T extends Account = Account> {
value: { [id: string]: CipherData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedCollections: (options?: StorageOptions) => Promise<{ [id: string]: CollectionData }>;
setEncryptedCollections: (
value: { [id: string]: CollectionData },
options?: StorageOptions,
) => Promise<void>;
getEncryptedPasswordGenerationHistory: (
options?: StorageOptions,
) => Promise<GeneratedPasswordHistory[]>;

View File

@@ -22,10 +22,8 @@ import { DeepJsonify } from "../../../types/deep-jsonify";
import { MasterKey, UserKey } from "../../../types/key";
import { UriMatchType } from "../../../vault/enums";
import { CipherData } from "../../../vault/models/data/cipher.data";
import { CollectionData } from "../../../vault/models/data/collection.data";
import { FolderData } from "../../../vault/models/data/folder.data";
import { CipherView } from "../../../vault/models/view/cipher.view";
import { CollectionView } from "../../../vault/models/view/collection.view";
import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info";
import { KdfType } from "../../enums";
import { Utils } from "../../misc/utils";
@@ -73,7 +71,7 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
}
export class DataEncryptionPair<TEncrypted, TDecrypted> {
encrypted?: { [id: string]: TEncrypted };
encrypted?: Record<string, TEncrypted>;
decrypted?: TDecrypted[];
}
@@ -92,10 +90,6 @@ export class AccountData {
folders? = new TemporaryDataEncryption<FolderData>();
localData?: any;
sends?: DataEncryptionPair<SendData, SendView> = new DataEncryptionPair<SendData, SendView>();
collections?: DataEncryptionPair<CollectionData, CollectionView> = new DataEncryptionPair<
CollectionData,
CollectionView
>();
policies?: DataEncryptionPair<PolicyData, Policy> = new DataEncryptionPair<PolicyData, Policy>();
passwordGenerationHistory?: EncryptionPair<
GeneratedPasswordHistory[],

View File

@@ -23,10 +23,8 @@ import { UserId } from "../../types/guid";
import { DeviceKey, MasterKey, UserKey } from "../../types/key";
import { UriMatchType } from "../../vault/enums";
import { CipherData } from "../../vault/models/data/cipher.data";
import { CollectionData } from "../../vault/models/data/collection.data";
import { LocalData } from "../../vault/models/data/local.data";
import { CipherView } from "../../vault/models/view/cipher.view";
import { CollectionView } from "../../vault/models/view/collection.view";
import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info";
import { EnvironmentService } from "../abstractions/environment.service";
import { LogService } from "../abstractions/log.service";
@@ -939,24 +937,6 @@ export class StateService<
);
}
@withPrototypeForArrayMembers(CollectionView)
async getDecryptedCollections(options?: StorageOptions): Promise<CollectionView[]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.data?.collections?.decrypted;
}
async setDecryptedCollections(value: CollectionView[], options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
account.data.collections.decrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()),
);
}
/**
* @deprecated Use UserKey instead
*/
@@ -1628,29 +1608,6 @@ export class StateService<
);
}
@withPrototypeForObjectValues(CollectionData)
async getEncryptedCollections(
options?: StorageOptions,
): Promise<{ [id: string]: CollectionData }> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
)?.data?.collections?.encrypted;
}
async setEncryptedCollections(
value: { [id: string]: CollectionData },
options?: StorageOptions,
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
);
account.data.collections.encrypted = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
);
}
/**
* @deprecated Use UserKey instead
*/

View File

@@ -50,6 +50,9 @@ export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk",
web: "disk-local",
});
export const COLLECTION_DATA = new StateDefinition("collection", "disk", {
web: "memory",
});
export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk");
export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", {
web: "disk-local",

View File

@@ -84,6 +84,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
if (userId == null || userId === (await this.stateService.getUserId())) {
this.searchService.clearIndex();
await this.folderService.clearCache();
await this.collectionService.clearActiveUserCache();
}
await this.stateService.setEverBeenUnlocked(true, { userId: userId });
@@ -96,7 +97,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.cryptoService.clearKeyPair(true, userId);
await this.cipherService.clearCache(userId);
await this.collectionService.clearCache(userId);
this.messagingService.send("locked", { userId: userId });

View File

@@ -15,6 +15,7 @@ import { EnablePasskeysMigrator } from "./migrations/17-move-enable-passkeys-to-
import { AutofillSettingsKeyMigrator } from "./migrations/18-move-autofill-settings-to-state-providers";
import { RequirePasswordOnStartMigrator } from "./migrations/19-migrate-require-password-on-start";
import { PrivateKeyMigrator } from "./migrations/20-move-private-key-to-state-providers";
import { CollectionMigrator } from "./migrations/21-move-collections-state-to-state-provider";
import { FixPremiumMigrator } from "./migrations/3-fix-premium";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
@@ -25,7 +26,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 20;
export const CURRENT_VERSION = 21;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -48,7 +49,8 @@ export function createMigrationBuilder() {
.with(EnablePasskeysMigrator, 16, 17)
.with(AutofillSettingsKeyMigrator, 17, 18)
.with(RequirePasswordOnStartMigrator, 18, 19)
.with(PrivateKeyMigrator, 19, CURRENT_VERSION);
.with(PrivateKeyMigrator, 19, 20)
.with(CollectionMigrator, 20, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -0,0 +1,196 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { CollectionMigrator } from "./21-move-collections-state-to-state-provider";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
collections: {
encrypted: {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
"user_user-1_collection_collections": {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
},
"user_user-2_collection_data": null as any,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2"],
"user-1": {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
data: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
describe("CollectionMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: CollectionMigrator;
const keyDefinitionLike = {
key: "collections",
stateDefinition: {
name: "collection",
},
};
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 20);
sut = new CollectionMigrator(20, 21);
});
it("should remove collections from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should set collections value for each account", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
});
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 21);
sut = new CollectionMigrator(20, 21);
});
it.each(["user-1", "user-2"])("should null out new values", async (userId) => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null);
});
it("should add collection values back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalled();
expect(helper.set).toHaveBeenCalledWith("user-1", {
data: {
collections: {
encrypted: {
"877fef70-be32-439e-8678-b0d80125653d": {
id: "877fef70-be32-439e-8678-b0d80125653d",
organizationId: "fe1ff6ef-d2d4-49f3-9c07-b0c7013998f9",
name: "2.MD9OMDsvYiU1CTSUxjHorw==|uFc4cZhnmQmK2LFCWbyeZg==|syk2d9JESeplxInLvP36BK5RhqS1c/i+ZQp5NR7EUA4=",
externalId: "",
readOnly: false,
manage: true,
hidePasswords: false,
},
"0d3fee82-3f81-434c-aed0-b0c200ee6c7a": {
id: "0d3fee82-3f81-434c-aed0-b0c200ee6c7a",
organizationId: "5f277723-6391-4b5c-add9-b0c200ee6967",
name: "2.GxnXkIbBCGFr57F6lT7+Ow==|3ctMg95FKquG3l+qfv8BgvaCbYzMmuhnukCEHXhUukE=|cJRZWq05xjPBayUgx6P6gsbtNVLi8exQwo8F1SfqQQ4=",
externalId: "",
readOnly: false,
manage: false,
hidePasswords: false,
},
},
},
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("should not try to restore values to missing accounts", async () => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith("user-3", any());
});
});
});

View File

@@ -0,0 +1,63 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type CollectionDataType = {
id: string;
organizationId: string;
name: string;
externalId: string;
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
};
type ExpectedAccountType = {
data?: {
collections?: {
encrypted?: Record<string, CollectionDataType>;
};
};
};
const USER_ENCRYPTED_COLLECTIONS: KeyDefinitionLike = {
key: "collections",
stateDefinition: {
name: "collection",
},
};
export class CollectionMigrator extends Migrator<20, 21> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = account?.data?.collections?.encrypted;
if (value != null) {
await helper.setToUser(userId, USER_ENCRYPTED_COLLECTIONS, value);
delete account.data.collections;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
const value = await helper.getFromUser(userId, USER_ENCRYPTED_COLLECTIONS);
if (account) {
account.data = Object.assign(account.data ?? {}, {
collections: {
encrypted: value,
},
});
await helper.set(userId, account);
}
await helper.setToUser(userId, USER_ENCRYPTED_COLLECTIONS, null);
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -4,4 +4,5 @@ export type Guid = Opaque<string, "Guid">;
export type UserId = Opaque<string, "UserId">;
export type OrganizationId = Opaque<string, "OrganizationId">;
export type CollectionId = Opaque<string, "CollectionId">;
export type ProviderId = Opaque<string, "ProviderId">;

View File

@@ -1,11 +1,18 @@
import { Observable } from "rxjs";
import { CollectionId } from "../../types/guid";
import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection";
import { TreeNode } from "../models/domain/tree-node";
import { CollectionView } from "../models/view/collection.view";
export abstract class CollectionService {
clearCache: (userId?: string) => Promise<void>;
clearActiveUserCache: () => Promise<void>;
encrypt: (model: CollectionView) => Promise<Collection>;
decryptedCollectionViews$: (ids: CollectionId[]) => Observable<CollectionView[]>;
/**
* @deprecated This method will soon be made private, use `decryptedCollectionViews$` instead.
*/
decryptMany: (collections: Collection[]) => Promise<CollectionView[]>;
get: (id: string) => Promise<Collection>;
getAll: () => Promise<Collection[]>;

View File

@@ -1,8 +1,11 @@
import { Jsonify } from "type-fest";
import { CollectionId, OrganizationId } from "../../../types/guid";
import { CollectionDetailsResponse } from "../response/collection.response";
export class CollectionData {
id: string;
organizationId: string;
id: CollectionId;
organizationId: OrganizationId;
name: string;
externalId: string;
readOnly: boolean;
@@ -18,4 +21,8 @@ export class CollectionData {
this.manage = response.manage;
this.hidePasswords = response.hidePasswords;
}
static fromJSON(obj: Jsonify<CollectionData>) {
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
}
}

View File

@@ -1,4 +1,5 @@
import { mockEnc } from "../../../../spec";
import { CollectionId, OrganizationId } from "../../../types/guid";
import { CollectionData } from "../data/collection.data";
import { Collection } from "./collection";
@@ -8,8 +9,8 @@ describe("Collection", () => {
beforeEach(() => {
data = {
id: "id",
organizationId: "orgId",
id: "id" as CollectionId,
organizationId: "orgId" as OrganizationId,
name: "encName",
externalId: "extId",
readOnly: true,

View File

@@ -1,9 +1,10 @@
import { SelectionReadOnlyResponse } from "../../../admin-console/models/response/selection-read-only.response";
import { BaseResponse } from "../../../models/response/base.response";
import { CollectionId, OrganizationId } from "../../../types/guid";
export class CollectionResponse extends BaseResponse {
id: string;
organizationId: string;
id: CollectionId;
organizationId: OrganizationId;
name: string;
externalId: string;

View File

@@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { Organization } from "../../../admin-console/models/domain/organization";
import { View } from "../../../models/view/view";
import { Collection } from "../domain/collection";
@@ -56,4 +58,8 @@ export class CollectionView implements View, ITreeNodeObject {
? org?.canDeleteAnyCollection || (!org?.limitCollectionCreationDeletion && this.manage)
: org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections;
}
static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView(new Collection()), obj);
}
}

View File

@@ -1,7 +1,18 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { Jsonify } from "type-fest";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import {
ActiveUserState,
KeyDefinition,
StateProvider,
COLLECTION_DATA,
DeriveDefinition,
DerivedState,
} from "../../platform/state";
import { CollectionId, UserId } from "../../types/guid";
import { CollectionService as CollectionServiceAbstraction } from "../../vault/abstractions/collection.service";
import { CollectionData } from "../models/data/collection.data";
import { Collection } from "../models/domain/collection";
@@ -9,17 +20,71 @@ import { TreeNode } from "../models/domain/tree-node";
import { CollectionView } from "../models/view/collection.view";
import { ServiceUtils } from "../service-utils";
const ENCRYPTED_COLLECTION_DATA_KEY = KeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DATA,
"collections",
{
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
},
);
const DECRYPTED_COLLECTION_DATA_KEY = DeriveDefinition.from<
Record<CollectionId, CollectionData>,
CollectionView[],
{ collectionService: CollectionService }
>(ENCRYPTED_COLLECTION_DATA_KEY, {
deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)),
derive: async (collections: Record<CollectionId, CollectionData>, { collectionService }) => {
const data: Collection[] = [];
for (const id in collections ?? {}) {
const collectionId = id as CollectionId;
data.push(new Collection(collections[collectionId]));
}
return await collectionService.decryptMany(data);
},
});
const NestingDelimiter = "/";
export class CollectionService implements CollectionServiceAbstraction {
private encryptedCollectionDataState: ActiveUserState<Record<CollectionId, CollectionData>>;
encryptedCollections$: Observable<Collection[]>;
private decryptedCollectionDataState: DerivedState<CollectionView[]>;
decryptedCollections$: Observable<CollectionView[]>;
decryptedCollectionViews$(ids: CollectionId[]): Observable<CollectionView[]> {
return this.decryptedCollections$.pipe(
map((collections) => collections.filter((c) => ids.includes(c.id as CollectionId))),
);
}
constructor(
private cryptoService: CryptoService,
private i18nService: I18nService,
private stateService: StateService,
) {}
protected stateProvider: StateProvider,
) {
this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY);
this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe(
map((collections) => {
const response: Collection[] = [];
for (const id in collections ?? {}) {
response.push(new Collection(collections[id as CollectionId]));
}
return response;
}),
);
async clearCache(userId?: string): Promise<void> {
await this.stateService.setDecryptedCollections(null, { userId: userId });
this.decryptedCollectionDataState = this.stateProvider.getDerived(
this.encryptedCollectionDataState.state$,
DECRYPTED_COLLECTION_DATA_KEY,
{ collectionService: this },
);
this.decryptedCollections$ = this.decryptedCollectionDataState.state$;
}
async clearActiveUserCache(): Promise<void> {
await this.decryptedCollectionDataState.forceValue(null);
}
async encrypt(model: CollectionView): Promise<Collection> {
@@ -52,43 +117,19 @@ export class CollectionService implements CollectionServiceAbstraction {
}
async get(id: string): Promise<Collection> {
const collections = await this.stateService.getEncryptedCollections();
// eslint-disable-next-line
if (collections == null || !collections.hasOwnProperty(id)) {
return null;
}
return new Collection(collections[id]);
return (
(await firstValueFrom(
this.encryptedCollections$.pipe(map((cs) => cs.find((c) => c.id === id))),
)) ?? null
);
}
async getAll(): Promise<Collection[]> {
const collections = await this.stateService.getEncryptedCollections();
const response: Collection[] = [];
for (const id in collections) {
// eslint-disable-next-line
if (collections.hasOwnProperty(id)) {
response.push(new Collection(collections[id]));
}
}
return response;
return await firstValueFrom(this.encryptedCollections$);
}
async getAllDecrypted(): Promise<CollectionView[]> {
let decryptedCollections = await this.stateService.getDecryptedCollections();
if (decryptedCollections != null) {
return decryptedCollections;
}
const hasKey = await this.cryptoService.hasUserKey();
if (!hasKey) {
throw new Error("No key.");
}
const collections = await this.getAll();
decryptedCollections = await this.decryptMany(collections);
await this.stateService.setDecryptedCollections(decryptedCollections);
return decryptedCollections;
return await firstValueFrom(this.decryptedCollections$);
}
async getAllNested(collections: CollectionView[] = null): Promise<TreeNode<CollectionView>[]> {
@@ -115,48 +156,51 @@ export class CollectionService implements CollectionServiceAbstraction {
return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode<CollectionView>;
}
async upsert(collection: CollectionData | CollectionData[]): Promise<any> {
let collections = await this.stateService.getEncryptedCollections();
async upsert(toUpdate: CollectionData | CollectionData[]): Promise<void> {
if (toUpdate == null) {
return;
}
await this.encryptedCollectionDataState.update((collections) => {
if (collections == null) {
collections = {};
}
if (collection instanceof CollectionData) {
const c = collection as CollectionData;
if (Array.isArray(toUpdate)) {
toUpdate.forEach((c) => {
collections[c.id] = c;
});
} else {
(collection as CollectionData[]).forEach((c) => {
collections[c.id] = c;
collections[toUpdate.id] = toUpdate;
}
return collections;
});
}
await this.replace(collections);
async replace(collections: Record<CollectionId, CollectionData>): Promise<void> {
await this.encryptedCollectionDataState.update(() => collections);
}
async replace(collections: { [id: string]: CollectionData }): Promise<any> {
await this.clearCache();
await this.stateService.setEncryptedCollections(collections);
async clear(userId?: UserId): Promise<any> {
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 clear(userId?: string): Promise<any> {
await this.clearCache(userId);
await this.stateService.setEncryptedCollections(null, { userId: userId });
}
async delete(id: string | string[]): Promise<any> {
const collections = await this.stateService.getEncryptedCollections();
async delete(id: CollectionId | CollectionId[]): Promise<any> {
await this.encryptedCollectionDataState.update((collections) => {
if (collections == null) {
return;
collections = {};
}
if (typeof id === "string") {
delete collections[id];
} else {
(id as string[]).forEach((i) => {
(id as CollectionId[]).forEach((i) => {
delete collections[i];
});
}
await this.replace(collections);
return collections;
});
}
}