diff --git a/packages/askar/src/wallet/AskarProfileWallet.ts b/packages/askar/src/wallet/AskarProfileWallet.ts index 8c6e272d70..c3586404de 100644 --- a/packages/askar/src/wallet/AskarProfileWallet.ts +++ b/packages/askar/src/wallet/AskarProfileWallet.ts @@ -1,6 +1,7 @@ import type { WalletConfig } from '@credo-ts/core' import { + WalletExportUnsupportedError, WalletDuplicateError, WalletNotFoundError, InjectionSymbols, @@ -151,7 +152,7 @@ export class AskarProfileWallet extends AskarBaseWallet { public async export() { // This PR should help with this: https://github.com/hyperledger/aries-askar/pull/159 - throw new WalletError('Exporting a profile is not supported.') + throw new WalletExportUnsupportedError('Exporting a profile is not supported.') } public async import() { diff --git a/packages/core/src/agent/BaseAgent.ts b/packages/core/src/agent/BaseAgent.ts index ac35b4e79a..116b07ce32 100644 --- a/packages/core/src/agent/BaseAgent.ts +++ b/packages/core/src/agent/BaseAgent.ts @@ -22,7 +22,6 @@ import { SdJwtVcApi } from '../modules/sd-jwt-vc' import { W3cCredentialsApi } from '../modules/vc/W3cCredentialsApi' import { StorageUpdateService } from '../storage' import { UpdateAssistant } from '../storage/migration/UpdateAssistant' -import { DEFAULT_UPDATE_CONFIG } from '../storage/migration/updates' import { WalletApi } from '../wallet' import { WalletError } from '../wallet/error' @@ -160,7 +159,7 @@ export abstract class BaseAgent - new Agent({ - config: { label, walletConfig: { id: utils.uuid(), key: utils.uuid() } }, - modules: { - askar: new AskarModule(askarModuleConfig), - dids: new DidsModule({ - resolvers: [new KeyDidResolver()], - registrars: [new KeyDidRegistrar()], - }), - }, - dependencies: agentDependencies, - }) +import { getInMemoryAgentOptions } from '../../../../tests' + +import { Agent, DidKey, getJwkFromKey, KeyType, TypedArrayEncoder } from '@credo-ts/core' describe('sd-jwt-vc end to end test', () => { - const issuer = getAgent('sdjwtvcissueragent') + const issuer = new Agent(getInMemoryAgentOptions('sd-jwt-vc-issuer-agent')) let issuerKey: Key let issuerDidUrl: string - const holder = getAgent('sdjwtvcholderagent') + const holder = new Agent(getInMemoryAgentOptions('sd-jwt-vc-holder-agent')) let holderKey: Key - const verifier = getAgent('sdjwtvcverifieragent') + const verifier = new Agent(getInMemoryAgentOptions('sd-jwt-vc-verifier-agent')) const verifierDid = 'did:key:zUC74VEqqhEHQcgv4zagSPkqFJxuNWuoBPKjJuHETEUeHLoSqWt92viSsmaWjy82y' beforeAll(async () => { diff --git a/packages/core/src/storage/migration/StorageUpdateService.ts b/packages/core/src/storage/migration/StorageUpdateService.ts index b5b196406d..62860244ae 100644 --- a/packages/core/src/storage/migration/StorageUpdateService.ts +++ b/packages/core/src/storage/migration/StorageUpdateService.ts @@ -5,11 +5,11 @@ import type { VersionString } from '../../utils/version' import { InjectionSymbols } from '../../constants' import { Logger } from '../../logger' import { injectable, inject } from '../../plugins' -import { isFirstVersionEqualToSecond, isFirstVersionHigherThanSecond, parseVersionString } from '../../utils/version' +import { isStorageUpToDate } from './isUpToDate' import { StorageVersionRecord } from './repository/StorageVersionRecord' import { StorageVersionRepository } from './repository/StorageVersionRepository' -import { CURRENT_FRAMEWORK_STORAGE_VERSION, INITIAL_STORAGE_VERSION } from './updates' +import { INITIAL_STORAGE_VERSION } from './updates' @injectable() export class StorageUpdateService { @@ -27,15 +27,8 @@ export class StorageUpdateService { } public async isUpToDate(agentContext: AgentContext, updateToVersion?: UpdateToVersion) { - const currentStorageVersion = parseVersionString(await this.getCurrentStorageVersion(agentContext)) - - const compareToVersion = parseVersionString(updateToVersion ?? CURRENT_FRAMEWORK_STORAGE_VERSION) - - const isUpToDate = - isFirstVersionEqualToSecond(currentStorageVersion, compareToVersion) || - isFirstVersionHigherThanSecond(currentStorageVersion, compareToVersion) - - return isUpToDate + const currentStorageVersion = await this.getCurrentStorageVersion(agentContext) + return isStorageUpToDate(currentStorageVersion, updateToVersion) } public async getCurrentStorageVersion(agentContext: AgentContext): Promise { diff --git a/packages/core/src/storage/migration/UpdateAssistant.ts b/packages/core/src/storage/migration/UpdateAssistant.ts index 0242e35770..b1df93c427 100644 --- a/packages/core/src/storage/migration/UpdateAssistant.ts +++ b/packages/core/src/storage/migration/UpdateAssistant.ts @@ -11,7 +11,12 @@ import { WalletError } from '../../wallet/error/WalletError' import { StorageUpdateService } from './StorageUpdateService' import { StorageUpdateError } from './error/StorageUpdateError' -import { CURRENT_FRAMEWORK_STORAGE_VERSION, supportedUpdates } from './updates' +import { DEFAULT_UPDATE_CONFIG, CURRENT_FRAMEWORK_STORAGE_VERSION, supportedUpdates } from './updates' + +export interface UpdateAssistantUpdateOptions { + updateToVersion?: UpdateToVersion + backupBeforeStorageUpdate?: boolean +} // eslint-disable-next-line @typescript-eslint/no-explicit-any export class UpdateAssistant = BaseAgent> { @@ -20,7 +25,7 @@ export class UpdateAssistant = BaseAgent> { private updateConfig: UpdateConfig private fileSystem: FileSystem - public constructor(agent: Agent, updateConfig: UpdateConfig) { + public constructor(agent: Agent, updateConfig: UpdateConfig = DEFAULT_UPDATE_CONFIG) { this.agent = agent this.updateConfig = updateConfig @@ -107,7 +112,7 @@ export class UpdateAssistant = BaseAgent> { return neededUpdates } - public async update(options?: { updateToVersion?: UpdateToVersion; backupBeforeStorageUpdate?: boolean }) { + public async update(options?: UpdateAssistantUpdateOptions) { const updateIdentifier = Date.now().toString() const updateToVersion = options?.updateToVersion diff --git a/packages/core/src/storage/migration/index.ts b/packages/core/src/storage/migration/index.ts index 477cfc3df5..4358c05472 100644 --- a/packages/core/src/storage/migration/index.ts +++ b/packages/core/src/storage/migration/index.ts @@ -3,3 +3,4 @@ export * from './repository/StorageVersionRepository' export * from './StorageUpdateService' export * from './UpdateAssistant' export { Update } from './updates' +export * from './isUpToDate' diff --git a/packages/core/src/storage/migration/isUpToDate.ts b/packages/core/src/storage/migration/isUpToDate.ts new file mode 100644 index 0000000000..393d9c3eb4 --- /dev/null +++ b/packages/core/src/storage/migration/isUpToDate.ts @@ -0,0 +1,17 @@ +import type { UpdateToVersion } from './updates' +import type { VersionString } from '../../utils/version' + +import { isFirstVersionEqualToSecond, isFirstVersionHigherThanSecond, parseVersionString } from '../../utils/version' + +import { CURRENT_FRAMEWORK_STORAGE_VERSION } from './updates' + +export function isStorageUpToDate(storageVersion: VersionString, updateToVersion?: UpdateToVersion) { + const currentStorageVersion = parseVersionString(storageVersion) + const compareToVersion = parseVersionString(updateToVersion ?? CURRENT_FRAMEWORK_STORAGE_VERSION) + + const isUpToDate = + isFirstVersionEqualToSecond(currentStorageVersion, compareToVersion) || + isFirstVersionHigherThanSecond(currentStorageVersion, compareToVersion) + + return isUpToDate +} diff --git a/packages/core/src/storage/migration/updates/0.4-0.5/__tests__/w3cCredentialRecord.test.ts b/packages/core/src/storage/migration/updates/0.4-0.5/__tests__/w3cCredentialRecord.test.ts index 3ed5b85ca5..41b1bd1a02 100644 --- a/packages/core/src/storage/migration/updates/0.4-0.5/__tests__/w3cCredentialRecord.test.ts +++ b/packages/core/src/storage/migration/updates/0.4-0.5/__tests__/w3cCredentialRecord.test.ts @@ -16,6 +16,7 @@ const dependencyManager = { const agentConfig = getAgentConfig('Migration W3cCredentialRecord 0.4-0.5') const agentContext = getAgentContext({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any dependencyManager: dependencyManager as any, }) diff --git a/packages/core/src/wallet/Wallet.ts b/packages/core/src/wallet/Wallet.ts index e4f395ad1f..a90e1e9f64 100644 --- a/packages/core/src/wallet/Wallet.ts +++ b/packages/core/src/wallet/Wallet.ts @@ -9,6 +9,16 @@ import type { } from '../types' import type { Buffer } from '../utils/buffer' +// Split up into WalletManager and Wallet instance +// WalletManager is responsible for: +// - create, open, delete, close, export, import +// Wallet is responsible for: +// - createKey, sign, verify, pack, unpack, generateNonce, generateWalletKey + +// - Split storage initialization from wallet initialization, as storage and wallet are not required to be the same +// - wallet handles key management, signing, and encryption +// - storage handles record storage and retrieval + export interface Wallet extends Disposable { isInitialized: boolean isProvisioned: boolean diff --git a/packages/tenants/src/TenantsApi.ts b/packages/tenants/src/TenantsApi.ts index 1361eca721..41da646b6e 100644 --- a/packages/tenants/src/TenantsApi.ts +++ b/packages/tenants/src/TenantsApi.ts @@ -1,23 +1,37 @@ -import type { CreateTenantOptions, GetTenantAgentOptions, WithTenantAgentCallback } from './TenantsApiOptions' +import type { + CreateTenantOptions, + GetTenantAgentOptions, + UpdateTenantStorageOptions, + WithTenantAgentCallback, +} from './TenantsApiOptions' import type { TenantRecord } from './repository' import type { DefaultAgentModules, ModulesMap, Query } from '@credo-ts/core' -import { AgentContext, inject, InjectionSymbols, AgentContextProvider, injectable, Logger } from '@credo-ts/core' +import { + isStorageUpToDate, + AgentContext, + inject, + injectable, + InjectionSymbols, + Logger, + UpdateAssistant, +} from '@credo-ts/core' import { TenantAgent } from './TenantAgent' +import { TenantAgentContextProvider } from './context/TenantAgentContextProvider' import { TenantRecordService } from './services' @injectable() export class TenantsApi { public readonly rootAgentContext: AgentContext private tenantRecordService: TenantRecordService - private agentContextProvider: AgentContextProvider + private agentContextProvider: TenantAgentContextProvider private logger: Logger public constructor( tenantRecordService: TenantRecordService, rootAgentContext: AgentContext, - @inject(InjectionSymbols.AgentContextProvider) agentContextProvider: AgentContextProvider, + @inject(InjectionSymbols.AgentContextProvider) agentContextProvider: TenantAgentContextProvider, @inject(InjectionSymbols.Logger) logger: Logger ) { this.tenantRecordService = tenantRecordService @@ -105,4 +119,26 @@ export class TenantsApi { this.logger.debug('Getting all tenants') return this.tenantRecordService.getAllTenants(this.rootAgentContext) } + + public async updateTenantStorage({ tenantId, updateOptions }: UpdateTenantStorageOptions) { + this.logger.debug(`Updating tenant storage for tenant '${tenantId}'`) + const tenantRecord = await this.tenantRecordService.getTenantById(this.rootAgentContext, tenantId) + + if (isStorageUpToDate(tenantRecord.storageVersion)) { + this.logger.debug(`Tenant storage for tenant '${tenantId}' is already up to date. Skipping update`) + return + } + + await this.agentContextProvider.updateTenantStorage(tenantRecord, updateOptions) + } + + public async getTenantsWithOutdatedStorage() { + const outdatedTenants = await this.tenantRecordService.findTenantsByQuery(this.rootAgentContext, { + $not: { + storageVersion: UpdateAssistant.frameworkStorageVersion, + }, + }) + + return outdatedTenants + } } diff --git a/packages/tenants/src/TenantsApiOptions.ts b/packages/tenants/src/TenantsApiOptions.ts index 68348c2022..9cc62a2938 100644 --- a/packages/tenants/src/TenantsApiOptions.ts +++ b/packages/tenants/src/TenantsApiOptions.ts @@ -1,6 +1,6 @@ import type { TenantAgent } from './TenantAgent' import type { TenantConfig } from './models/TenantConfig' -import type { ModulesMap } from '@credo-ts/core' +import type { ModulesMap, UpdateAssistantUpdateOptions } from '@credo-ts/core' export interface GetTenantAgentOptions { tenantId: string @@ -13,3 +13,8 @@ export type WithTenantAgentCallback = ( export interface CreateTenantOptions { config: Omit } + +export interface UpdateTenantStorageOptions { + tenantId: string + updateOptions?: UpdateAssistantUpdateOptions +} diff --git a/packages/tenants/src/__tests__/TenantsApi.test.ts b/packages/tenants/src/__tests__/TenantsApi.test.ts index 917485106b..2943f4ab8f 100644 --- a/packages/tenants/src/__tests__/TenantsApi.test.ts +++ b/packages/tenants/src/__tests__/TenantsApi.test.ts @@ -151,6 +151,7 @@ describe('TenantsApi', () => { key: 'Wallet: TenantsApi: tenant-id', }, }, + storageVersion: '0.5', }) const tenantAgentMock = { diff --git a/packages/tenants/src/context/TenantAgentContextProvider.ts b/packages/tenants/src/context/TenantAgentContextProvider.ts index c3cbe9a3e9..7eca4d2b1f 100644 --- a/packages/tenants/src/context/TenantAgentContextProvider.ts +++ b/packages/tenants/src/context/TenantAgentContextProvider.ts @@ -1,6 +1,14 @@ -import type { AgentContextProvider, RoutingCreatedEvent, EncryptedMessage } from '@credo-ts/core' +import type { TenantRecord } from '../repository' +import type { + AgentContextProvider, + RoutingCreatedEvent, + EncryptedMessage, + UpdateAssistantUpdateOptions, +} from '@credo-ts/core' import { + isStorageUpToDate, + UpdateAssistant, CredoError, injectable, AgentContext, @@ -16,6 +24,7 @@ import { isJsonObject, } from '@credo-ts/core' +import { TenantAgent } from '../TenantAgent' import { TenantRecordService } from '../services' import { TenantSessionCoordinator } from './TenantSessionCoordinator' @@ -48,7 +57,21 @@ export class TenantAgentContextProvider implements AgentContextProvider { public async getAgentContextForContextCorrelationId(tenantId: string) { // TODO: maybe we can look at not having to retrieve the tenant record if there's already a context available. const tenantRecord = await this.tenantRecordService.getTenantById(this.rootAgentContext, tenantId) - const agentContext = this.tenantSessionCoordinator.getContextForSession(tenantRecord) + const shouldUpdate = !isStorageUpToDate(tenantRecord.storageVersion) + + // If the tenant storage is not up to date, and autoUpdate is disabled we throw an error + if (shouldUpdate && !this.rootAgentContext.config.autoUpdateStorageOnStartup) { + throw new CredoError( + `Current agent storage for tenant ${tenantRecord.id} is not up to date. ` + + `To prevent the tenant state from getting corrupted the tenant initialization is aborted. ` + + `Make sure to update the tenant storage (currently at ${tenantRecord.storageVersion}) to the latest version (${UpdateAssistant.frameworkStorageVersion}). ` + + `You can also downgrade your version of Credo.` + ) + } + + const agentContext = await this.tenantSessionCoordinator.getContextForSession(tenantRecord, { + runInMutex: shouldUpdate ? (agentContext) => this._updateTenantStorage(tenantRecord, agentContext) : undefined, + }) this.logger.debug(`Created tenant agent context for tenant '${tenantId}'`) @@ -145,4 +168,55 @@ export class TenantAgentContextProvider implements AgentContextProvider { await this.registerRecipientKeyForTenant(contextCorrelationId, recipientKey) }) } + + /** + * Method to allow updating the tenant storage, this method can be called from the TenantsApi + * to update the storage for a tenant manually + */ + public async updateTenantStorage(tenantRecord: TenantRecord, updateOptions?: UpdateAssistantUpdateOptions) { + await this.tenantSessionCoordinator.getContextForSession(tenantRecord, { + // runInMutex allows us to run the updateTenantStorage method in a mutex lock + // prevent other sessions from being started while the update is in progress + runInMutex: (agentContext) => this._updateTenantStorage(tenantRecord, agentContext, updateOptions), + }) + } + + /** + * Handle the case where the tenant storage is outdated. If auto-update is disabled we will throw an error + * and not update the storage. If auto-update is enabled we will update the storage. + * + * When this method is called we can be sure that we are in the mutex runExclusive lock and thus other sessions + * will not be able to open a session for this tenant until we're done. + * + * NOTE: We don't support multi-instance locking for now. That means you can only have a single instance open and + * it will prevent multiple processes from updating the tenant storage at the same time. However if multi-instances + * are used, we can't prevent multiple instances from updating the tenant storage at the same time. + * In the future we can make the tenantSessionCoordinator an interface and allowing a instance-tenant-lock as well + * as an tenant-lock (across all instances) + */ + private async _updateTenantStorage( + tenantRecord: TenantRecord, + agentContext: AgentContext, + updateOptions?: UpdateAssistantUpdateOptions + ) { + try { + // Update the tenant storage + const tenantAgent = new TenantAgent(agentContext) + const updateAssistant = new UpdateAssistant(tenantAgent) + await updateAssistant.initialize() + await updateAssistant.update({ + ...updateOptions, + backupBeforeStorageUpdate: + updateOptions?.backupBeforeStorageUpdate ?? agentContext.config.backupBeforeStorageUpdate, + }) + + // Update the storage version in the tenant record + tenantRecord.storageVersion = await updateAssistant.getCurrentAgentStorageVersion() + const tenantRecordService = this.rootAgentContext.dependencyManager.resolve(TenantRecordService) + await tenantRecordService.updateTenant(this.rootAgentContext, tenantRecord) + } catch (error) { + this.logger.error(`Error occurred while updating tenant storage for tenant ${tenantRecord.id}`, error) + throw error + } + } } diff --git a/packages/tenants/src/context/TenantSessionCoordinator.ts b/packages/tenants/src/context/TenantSessionCoordinator.ts index 7445eb5946..b7d81ff70b 100644 --- a/packages/tenants/src/context/TenantSessionCoordinator.ts +++ b/packages/tenants/src/context/TenantSessionCoordinator.ts @@ -46,11 +46,10 @@ export class TenantSessionCoordinator { this.logger = logger this.tenantsModuleConfig = tenantsModuleConfig - // TODO: we should make the timeout and the session limit configurable, but until we have the modularization in place with - // module specific config, it's not easy to do so. Keeping it hardcoded for now this.sessionMutex = new TenantSessionMutex( this.logger, this.tenantsModuleConfig.sessionLimit, + // TODO: we should probably allow a higher session acquire timeout if the storage is being updated? this.tenantsModuleConfig.sessionAcquireTimeout ) } @@ -59,8 +58,18 @@ export class TenantSessionCoordinator { * Get agent context to use for a session. If an agent context for this tenant does not exist yet * it will create it and store it for later use. If the agent context does already exist it will * be returned. + * + * @parm tenantRecord The tenant record for which to get the agent context */ - public async getContextForSession(tenantRecord: TenantRecord): Promise { + public async getContextForSession( + tenantRecord: TenantRecord, + { + runInMutex, + }: { + /** optional callback that will be run inside the mutex lock */ + runInMutex?: (agentContext: AgentContext) => Promise + } = {} + ): Promise { this.logger.debug(`Getting context for session with tenant '${tenantRecord.id}'`) // Wait for a session to be available @@ -83,6 +92,11 @@ export class TenantSessionCoordinator { this.logger.debug( `Increased agent context session count for tenant '${tenantRecord.id}' to ${tenantSessions.sessionCount}` ) + + if (runInMutex) { + await runInMutex(tenantSessions.agentContext) + } + return tenantSessions.agentContext }) } catch (error) { diff --git a/packages/tenants/src/context/__tests__/TenantAgentContextProvider.test.ts b/packages/tenants/src/context/__tests__/TenantAgentContextProvider.test.ts index 08eafa629e..2b84f82b3b 100644 --- a/packages/tenants/src/context/__tests__/TenantAgentContextProvider.test.ts +++ b/packages/tenants/src/context/__tests__/TenantAgentContextProvider.test.ts @@ -57,6 +57,7 @@ describe('TenantAgentContextProvider', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) const tenantAgentContext = jest.fn() as unknown as AgentContext @@ -67,7 +68,9 @@ describe('TenantAgentContextProvider', () => { const returnedAgentContext = await tenantAgentContextProvider.getAgentContextForContextCorrelationId('tenant1') expect(tenantRecordService.getTenantById).toHaveBeenCalledWith(rootAgentContext, 'tenant1') - expect(tenantSessionCoordinator.getContextForSession).toHaveBeenCalledWith(tenantRecord) + expect(tenantSessionCoordinator.getContextForSession).toHaveBeenCalledWith(tenantRecord, { + runInMutex: undefined, + }) expect(returnedAgentContext).toBe(tenantAgentContext) }) }) @@ -83,6 +86,7 @@ describe('TenantAgentContextProvider', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) const tenantAgentContext = jest.fn() as unknown as AgentContext @@ -96,7 +100,9 @@ describe('TenantAgentContextProvider', () => { ) expect(tenantRecordService.getTenantById).toHaveBeenCalledWith(rootAgentContext, 'tenant1') - expect(tenantSessionCoordinator.getContextForSession).toHaveBeenCalledWith(tenantRecord) + expect(tenantSessionCoordinator.getContextForSession).toHaveBeenCalledWith(tenantRecord, { + runInMutex: undefined, + }) expect(returnedAgentContext).toBe(tenantAgentContext) expect(tenantRecordService.findTenantRoutingRecordByRecipientKey).not.toHaveBeenCalled() }) @@ -125,6 +131,7 @@ describe('TenantAgentContextProvider', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) const tenantAgentContext = jest.fn() as unknown as AgentContext @@ -136,7 +143,9 @@ describe('TenantAgentContextProvider', () => { const returnedAgentContext = await tenantAgentContextProvider.getContextForInboundMessage(inboundMessage) expect(tenantRecordService.getTenantById).toHaveBeenCalledWith(rootAgentContext, 'tenant1') - expect(tenantSessionCoordinator.getContextForSession).toHaveBeenCalledWith(tenantRecord) + expect(tenantSessionCoordinator.getContextForSession).toHaveBeenCalledWith(tenantRecord, { + runInMutex: undefined, + }) expect(returnedAgentContext).toBe(tenantAgentContext) expect(tenantRecordService.findTenantRoutingRecordByRecipientKey).toHaveBeenCalledWith( rootAgentContext, diff --git a/packages/tenants/src/context/__tests__/TenantSessionCoordinator.test.ts b/packages/tenants/src/context/__tests__/TenantSessionCoordinator.test.ts index 7d35dc7fb0..a480832174 100644 --- a/packages/tenants/src/context/__tests__/TenantSessionCoordinator.test.ts +++ b/packages/tenants/src/context/__tests__/TenantSessionCoordinator.test.ts @@ -65,6 +65,7 @@ describe('TenantSessionCoordinator', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) const tenantAgentContext = await tenantSessionCoordinator.getContextForSession(tenantRecord) @@ -83,6 +84,7 @@ describe('TenantSessionCoordinator', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) const createChildSpy = jest.spyOn(agentContext.dependencyManager, 'createChild') const extendSpy = jest.spyOn(agentContext.config, 'extend') @@ -135,6 +137,7 @@ describe('TenantSessionCoordinator', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) // Throw error during wallet initialization @@ -160,6 +163,7 @@ describe('TenantSessionCoordinator', () => { key: 'test-wallet-key', }, }, + storageVersion: '0.5', }) // Add timeout to mock the initialization and we can test that the mutex is used. diff --git a/packages/tenants/src/repository/TenantRecord.ts b/packages/tenants/src/repository/TenantRecord.ts index b8a33d8887..56e866ded4 100644 --- a/packages/tenants/src/repository/TenantRecord.ts +++ b/packages/tenants/src/repository/TenantRecord.ts @@ -1,5 +1,5 @@ import type { TenantConfig } from '../models/TenantConfig' -import type { RecordTags, TagsBase } from '@credo-ts/core' +import type { RecordTags, TagsBase, VersionString } from '@credo-ts/core' import { BaseRecord, utils } from '@credo-ts/core' @@ -10,10 +10,12 @@ export interface TenantRecordProps { createdAt?: Date config: TenantConfig tags?: TagsBase + storageVersion: VersionString } export type DefaultTenantRecordTags = { label: string + storageVersion: VersionString } export class TenantRecord extends BaseRecord { @@ -22,6 +24,15 @@ export class TenantRecord extends BaseRecord { public config!: TenantConfig + /** + * The storage version that is used by this tenant. Can be used to know if the tenant is ready to be used + * with the current version of the application. + * + * @default 0.4 from 0.5 onwards we set the storage version on creation, so if no value + * is stored, it means the storage version is 0.4 (when multi-tenancy was introduced) + */ + public storageVersion: VersionString = '0.4' + public constructor(props: TenantRecordProps) { super() @@ -30,6 +41,7 @@ export class TenantRecord extends BaseRecord { this.createdAt = props.createdAt ?? new Date() this._tags = props.tags ?? {} this.config = props.config + this.storageVersion = props.storageVersion } } @@ -37,6 +49,7 @@ export class TenantRecord extends BaseRecord { return { ...this._tags, label: this.config.label, + storageVersion: this.storageVersion, } } } diff --git a/packages/tenants/src/repository/__tests__/TenantRecord.test.ts b/packages/tenants/src/repository/__tests__/TenantRecord.test.ts index 6c68b0ee3a..6ba6b23344 100644 --- a/packages/tenants/src/repository/__tests__/TenantRecord.test.ts +++ b/packages/tenants/src/repository/__tests__/TenantRecord.test.ts @@ -18,20 +18,23 @@ describe('TenantRecord', () => { key: 'test', }, }, + storageVersion: '0.5', }) expect(tenantRecord.type).toBe('TenantRecord') expect(tenantRecord.id).toBe('tenant-id') expect(tenantRecord.createdAt).toBe(createdAt) - expect(tenantRecord.config).toMatchObject({ + expect(tenantRecord.config).toEqual({ label: 'test', walletConfig: { id: 'test', key: 'test', }, }) - expect(tenantRecord.getTags()).toMatchObject({ + expect(tenantRecord.getTags()).toEqual({ + label: 'test', some: 'tag', + storageVersion: '0.5', }) }) @@ -50,6 +53,7 @@ describe('TenantRecord', () => { key: 'test', }, }, + storageVersion: '0.5', }) const json = tenantRecord.toJSON() @@ -57,6 +61,7 @@ describe('TenantRecord', () => { id: 'tenant-id', createdAt: '2022-02-02T00:00:00.000Z', metadata: {}, + storageVersion: '0.5', _tags: { some: 'tag', }, @@ -74,15 +79,17 @@ describe('TenantRecord', () => { expect(instance.type).toBe('TenantRecord') expect(instance.id).toBe('tenant-id') expect(instance.createdAt.getTime()).toBe(createdAt.getTime()) - expect(instance.config).toMatchObject({ + expect(instance.config).toEqual({ label: 'test', walletConfig: { id: 'test', key: 'test', }, }) - expect(instance.getTags()).toMatchObject({ + expect(instance.getTags()).toEqual({ + label: 'test', some: 'tag', + storageVersion: '0.5', }) }) }) diff --git a/packages/tenants/src/services/TenantRecordService.ts b/packages/tenants/src/services/TenantRecordService.ts index 28280a2e1d..716dc117ec 100644 --- a/packages/tenants/src/services/TenantRecordService.ts +++ b/packages/tenants/src/services/TenantRecordService.ts @@ -1,7 +1,7 @@ import type { TenantConfig } from '../models/TenantConfig' import type { AgentContext, Key, Query } from '@credo-ts/core' -import { injectable, utils, KeyDerivationMethod } from '@credo-ts/core' +import { UpdateAssistant, injectable, utils, KeyDerivationMethod } from '@credo-ts/core' import { TenantRepository, TenantRecord, TenantRoutingRepository, TenantRoutingRecord } from '../repository' @@ -31,6 +31,7 @@ export class TenantRecordService { keyDerivationMethod: KeyDerivationMethod.Raw, }, }, + storageVersion: UpdateAssistant.frameworkStorageVersion, }) await this.tenantRepository.save(agentContext, tenantRecord) diff --git a/packages/tenants/src/services/__tests__/TenantService.test.ts b/packages/tenants/src/services/__tests__/TenantService.test.ts index 112c880eba..f84454dbbc 100644 --- a/packages/tenants/src/services/__tests__/TenantService.test.ts +++ b/packages/tenants/src/services/__tests__/TenantService.test.ts @@ -73,6 +73,7 @@ describe('TenantRecordService', () => { key: 'tenant-wallet-key', }, }, + storageVersion: '0.5', }) mockFunction(tenantRepository.getById).mockResolvedValue(tenantRecord) mockFunction(tenantRoutingRepository.findByQuery).mockResolvedValue([]) @@ -92,6 +93,7 @@ describe('TenantRecordService', () => { key: 'tenant-wallet-key', }, }, + storageVersion: '0.5', }) const tenantRoutingRecords = [ new TenantRoutingRecord({ diff --git a/packages/tenants/src/updates/__tests__/__snapshots__/0.4.test.ts.snap b/packages/tenants/src/updates/__tests__/__snapshots__/0.4.test.ts.snap index 3474b0ad3e..365a2cafb1 100644 --- a/packages/tenants/src/updates/__tests__/__snapshots__/0.4.test.ts.snap +++ b/packages/tenants/src/updates/__tests__/__snapshots__/0.4.test.ts.snap @@ -6,6 +6,7 @@ exports[`UpdateAssistant | Tenants | v0.4 - v0.5 should correctly update the ten "id": "1-4e4f-41d9-94c4-f49351b811f1", "tags": { "label": "Tenant 1", + "storageVersion": "0.4", }, "type": "TenantRecord", "value": { @@ -20,6 +21,7 @@ exports[`UpdateAssistant | Tenants | v0.4 - v0.5 should correctly update the ten "createdAt": "2023-11-23T22:50:20.522Z", "id": "1-4e4f-41d9-94c4-f49351b811f1", "metadata": {}, + "storageVersion": "0.4", "updatedAt": "2023-11-23T22:50:20.522Z", }, }, @@ -27,6 +29,7 @@ exports[`UpdateAssistant | Tenants | v0.4 - v0.5 should correctly update the ten "id": "2-4e4f-41d9-94c4-f49351b811f1", "tags": { "label": "Tenant 2", + "storageVersion": "0.4", }, "type": "TenantRecord", "value": { @@ -41,6 +44,7 @@ exports[`UpdateAssistant | Tenants | v0.4 - v0.5 should correctly update the ten "createdAt": "2023-11-23T22:50:20.522Z", "id": "2-4e4f-41d9-94c4-f49351b811f1", "metadata": {}, + "storageVersion": "0.4", "updatedAt": "2023-11-23T22:50:20.522Z", }, }, diff --git a/packages/tenants/tests/tenants-04.db b/packages/tenants/tests/tenants-04.db new file mode 100644 index 0000000000..d2238be9b9 Binary files /dev/null and b/packages/tenants/tests/tenants-04.db differ diff --git a/packages/tenants/tests/tenants-storage-update.test.ts b/packages/tenants/tests/tenants-storage-update.test.ts new file mode 100644 index 0000000000..e26b6ae421 --- /dev/null +++ b/packages/tenants/tests/tenants-storage-update.test.ts @@ -0,0 +1,237 @@ +import type { InitConfig, FileSystem } from '@credo-ts/core' + +import { + UpdateAssistant, + InjectionSymbols, + ConnectionsModule, + Agent, + CacheModule, + InMemoryLruCache, +} from '@credo-ts/core' +import { agentDependencies } from '@credo-ts/node' +import path from 'path' + +import { AskarModule, AskarMultiWalletDatabaseScheme } from '../../askar/src' +import { ariesAskar } from '../../askar/tests/helpers' +import { testLogger } from '../../core/tests' + +import { TenantsModule } from '@credo-ts/tenants' + +const agentConfig = { + label: 'Tenant Agent', + walletConfig: { + id: `tenants-agent-04`, + key: `tenants-agent-04`, + }, + logger: testLogger, +} satisfies InitConfig + +const modules = { + tenants: new TenantsModule(), + askar: new AskarModule({ + ariesAskar, + multiWalletDatabaseScheme: AskarMultiWalletDatabaseScheme.ProfilePerWallet, + }), + connections: new ConnectionsModule({ + autoAcceptConnections: true, + }), + cache: new CacheModule({ + cache: new InMemoryLruCache({ limit: 500 }), + }), +} as const + +describe('Tenants Storage Update', () => { + test('auto update storage', async () => { + // Create multi-tenant agents + const agent = new Agent({ + config: { + ...agentConfig, + autoUpdateStorageOnStartup: true, + + // export not supported for askar profile wallet + // so we skip creating a backup + backupBeforeStorageUpdate: false, + }, + modules, + dependencies: agentDependencies, + }) + + // Delete existing wallet at this path + const fileSystem = agent.dependencyManager.resolve(InjectionSymbols.FileSystem) + await fileSystem.delete(path.join(fileSystem.dataPath, 'wallet', agentConfig.walletConfig.id)) + + // Import the wallet + await agent.wallet.import(agentConfig.walletConfig, { + key: agentConfig.walletConfig.key, + path: path.join(__dirname, 'tenants-04.db'), + }) + + await agent.initialize() + + // Expect tenant storage version to be still 0.4 + const tenant = await agent.modules.tenants.getTenantById('1d45d3c2-3480-4375-ac6f-47c322f091b0') + expect(tenant.storageVersion).toBe('0.4') + + // Open/close tenant agent so that the storage is updated + await ( + await agent.modules.tenants.getTenantAgent({ tenantId: '1d45d3c2-3480-4375-ac6f-47c322f091b0' }) + ).endSession() + + // Expect tenant storage version to be 0.5 + const updatedTenant = await agent.modules.tenants.getTenantById('1d45d3c2-3480-4375-ac6f-47c322f091b0') + expect(updatedTenant.storageVersion).toBe('0.5') + + await agent.wallet.delete() + await agent.shutdown() + }) + + test('error when trying to open session for tenant when backupBeforeStorageUpdate is not disabled because profile cannot be exported', async () => { + // Create multi-tenant agents + const agent = new Agent({ + config: { ...agentConfig, autoUpdateStorageOnStartup: true, backupBeforeStorageUpdate: true }, + modules, + dependencies: agentDependencies, + }) + + // Delete existing wallet at this path + const fileSystem = agent.dependencyManager.resolve(InjectionSymbols.FileSystem) + await fileSystem.delete(path.join(fileSystem.dataPath, 'wallet', agentConfig.walletConfig.id)) + + // Import the wallet + await agent.wallet.import(agentConfig.walletConfig, { + key: agentConfig.walletConfig.key, + path: path.join(__dirname, 'tenants-04.db'), + }) + + // Initialize agent + await agent.initialize() + + // Expect tenant storage version to be still 0.4 + const tenant = await agent.modules.tenants.getTenantById('1d45d3c2-3480-4375-ac6f-47c322f091b0') + expect(tenant.storageVersion).toBe('0.4') + + // Should throw error because not up to date and backupBeforeStorageUpdate is true + await expect( + agent.modules.tenants.getTenantAgent({ tenantId: '1d45d3c2-3480-4375-ac6f-47c322f091b0' }) + ).rejects.toThrow(/the wallet backend does not support exporting/) + + await agent.wallet.delete() + await agent.shutdown() + }) + + test('error when trying to open session for tenant when autoUpdateStorageOnStartup is disabled', async () => { + // Create multi-tenant agents + const agent = new Agent({ + config: agentConfig, + modules, + dependencies: agentDependencies, + }) + + // Delete existing wallet at this path + const fileSystem = agent.dependencyManager.resolve(InjectionSymbols.FileSystem) + await fileSystem.delete(path.join(fileSystem.dataPath, 'wallet', agentConfig.walletConfig.id)) + + // Import the wallet + await agent.wallet.import(agentConfig.walletConfig, { + key: agentConfig.walletConfig.key, + path: path.join(__dirname, 'tenants-04.db'), + }) + + // Update root agent (but not tenants) + const updateAssistant = new UpdateAssistant(agent) + await updateAssistant.initialize() + await updateAssistant.update() + + // Initialize agent + await agent.initialize() + + // Expect tenant storage version to be still 0.4 + const tenant = await agent.modules.tenants.getTenantById('1d45d3c2-3480-4375-ac6f-47c322f091b0') + expect(tenant.storageVersion).toBe('0.4') + + // Should throw error because not up to date and autoUpdateStorageOnStartup is not true + await expect( + agent.modules.tenants.getTenantAgent({ tenantId: '1d45d3c2-3480-4375-ac6f-47c322f091b0' }) + ).rejects.toThrow(/Current agent storage for tenant 1d45d3c2-3480-4375-ac6f-47c322f091b0 is not up to date/) + + await agent.wallet.delete() + await agent.shutdown() + }) + + test('update tenant agent manually using update assistant', async () => { + // Create multi-tenant agents + const agent = new Agent({ + config: agentConfig, + modules, + dependencies: agentDependencies, + }) + + // Delete existing wallet at this path + const fileSystem = agent.dependencyManager.resolve(InjectionSymbols.FileSystem) + await fileSystem.delete(path.join(fileSystem.dataPath, 'wallet', agentConfig.walletConfig.id)) + + // Import the wallet + await agent.wallet.import(agentConfig.walletConfig, { + key: agentConfig.walletConfig.key, + path: path.join(__dirname, 'tenants-04.db'), + }) + + // Update root agent (but not tenants) + const updateAssistant = new UpdateAssistant(agent) + await updateAssistant.initialize() + await updateAssistant.update() + + // Initialize agent + await agent.initialize() + + // Expect tenant storage version to be still 0.4 + const tenant = await agent.modules.tenants.getTenantById('1d45d3c2-3480-4375-ac6f-47c322f091b0') + expect(tenant.storageVersion).toBe('0.4') + + // Getting tenant should now throw error because not up to date + await expect( + agent.modules.tenants.getTenantAgent({ tenantId: '1d45d3c2-3480-4375-ac6f-47c322f091b0' }) + ).rejects.toThrow(/Current agent storage for tenant 1d45d3c2-3480-4375-ac6f-47c322f091b0 is not up to date/) + + // Update tenant + await agent.modules.tenants.updateTenantStorage({ + tenantId: tenant.id, + updateOptions: { + backupBeforeStorageUpdate: false, + }, + }) + + // Expect tenant storage version to be 0.5 + const updatedTenant = await agent.modules.tenants.getTenantById('1d45d3c2-3480-4375-ac6f-47c322f091b0') + expect(updatedTenant.storageVersion).toBe('0.5') + + // Getting tenant should now work + await expect( + agent.modules.tenants.withTenantAgent({ tenantId: '1d45d3c2-3480-4375-ac6f-47c322f091b0' }, async () => { + /* no-op */ + }) + ).resolves.toBeUndefined() + + const outdatedTenants = await agent.modules.tenants.getTenantsWithOutdatedStorage() + expect(outdatedTenants).toHaveLength(2) + + // Update tenants in parallel + const updatePromises = outdatedTenants.map((tenant) => + agent.modules.tenants.updateTenantStorage({ + tenantId: tenant.id, + updateOptions: { + backupBeforeStorageUpdate: false, + }, + }) + ) + + await Promise.all(updatePromises) + + // Now there should be no outdated tenants + const outdatedTenantsAfterUpdate = await agent.modules.tenants.getTenantsWithOutdatedStorage() + expect(outdatedTenantsAfterUpdate).toHaveLength(0) + + await agent.wallet.delete() + await agent.shutdown() + }) +})