diff --git a/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js new file mode 100644 index 0000000000..85afdf458e --- /dev/null +++ b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('assets', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "assets" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('assets', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('assets', (table) => { + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/src/accounting/psql/balance.test.ts b/packages/backend/src/accounting/psql/balance.test.ts index 559bbb229c..027c6f1eff 100644 --- a/packages/backend/src/accounting/psql/balance.test.ts +++ b/packages/backend/src/accounting/psql/balance.test.ts @@ -4,7 +4,7 @@ import { createTestApp, TestContainer } from '../../tests/app' import { Config } from '../../config/app' import { initIocContainer } from '../../' import { Asset } from '../../asset/model' -import { randomAsset } from '../../tests/asset' +import { createAsset } from '../../tests/asset' import { truncateTables } from '../../tests/tableManager' import { LedgerAccount } from './ledger-account/model' import { createLedgerAccount } from '../../tests/ledgerAccount' @@ -12,15 +12,18 @@ import { getAccountBalances } from './balance' import { ServiceDependencies } from './service' import { LedgerTransferState } from '../service' import { createLedgerTransfer } from '../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' describe('Balances', (): void => { + let deps: IocContract let serviceDeps: ServiceDependencies let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) serviceDeps = { logger: await deps.use('logger'), @@ -31,7 +34,7 @@ describe('Balances', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await createAsset(deps) }) afterEach(async (): Promise => { @@ -48,7 +51,7 @@ describe('Balances', (): void => { let peerAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await createAsset(deps) ;[account, peerAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex) diff --git a/packages/backend/src/accounting/psql/ledger-account/index.test.ts b/packages/backend/src/accounting/psql/ledger-account/index.test.ts index 4d5b0140ec..fdb9a898c2 100644 --- a/packages/backend/src/accounting/psql/ledger-account/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-account/index.test.ts @@ -32,7 +32,10 @@ describe('Ledger Account', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { diff --git a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts index 4a75ce60cc..3efb12f752 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts @@ -45,7 +45,10 @@ describe('Ledger Transfer', (): void => { let settlementAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[account, peerAccount, settlementAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex), diff --git a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts index 63dbcd8cef..aea03c66f5 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts @@ -9,14 +9,17 @@ import { LedgerAccount, LedgerAccountType } from '../ledger-account/model' import { createLedgerAccount } from '../../../tests/ledgerAccount' import { LedgerTransferState } from '../../service' import { createLedgerTransfer } from '../../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../../app' describe('Ledger Transfer Model', (): void => { + let deps: IocContract let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) knex = appContainer.knex }) @@ -25,7 +28,10 @@ describe('Ledger Transfer Model', (): void => { let debitAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[creditAccount, debitAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount( diff --git a/packages/backend/src/accounting/psql/service.test.ts b/packages/backend/src/accounting/psql/service.test.ts index 316cdf811e..e799068137 100644 --- a/packages/backend/src/accounting/psql/service.test.ts +++ b/packages/backend/src/accounting/psql/service.test.ts @@ -54,7 +54,10 @@ describe('Psql Accounting Service', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { @@ -892,7 +895,10 @@ describe('Psql Accounting Service', (): void => { const timeout = 10 // 10 seconds beforeEach(async (): Promise => { - const sourceAsset = await assetService.create(randomAsset()) + const sourceAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(sourceAsset)) sourceAccount = await accountFactory.build({ @@ -902,7 +908,10 @@ describe('Psql Accounting Service', (): void => { const destinationAsset = sameAsset ? sourceAsset - : await assetService.create(randomAsset()) + : await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(destinationAsset)) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index db0e174012..38fb458ad0 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -105,7 +105,9 @@ import { getTenantFromApiSignature, TenantApiSignatureResult } from './shared/utils' +import { TenantService } from './tenants/service' import { AuthServiceClient } from './auth-service-client/client' + export interface AppContextData { logger: Logger container: AppContainer @@ -266,6 +268,7 @@ export interface AppServices { paymentMethodHandlerService: Promise ilpPaymentService: Promise localPaymentService: Promise + tenantService: Promise authServiceClient: AuthServiceClient } @@ -395,21 +398,52 @@ export class App { ) let tenantApiSignatureResult: TenantApiSignatureResult - if (this.config.env !== 'test') { - koa.use(async (ctx, next: Koa.Next): Promise => { - const result = await getTenantFromApiSignature(ctx, this.config) - if (!result) { - ctx.throw(401, 'Unauthorized') - } else { + const tenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + const result = await getTenantFromApiSignature(ctx, this.config) + if (!result) { + ctx.throw(401, 'Unauthorized') + } else { + tenantApiSignatureResult = { + tenant: result.tenant, + isOperator: result.isOperator ? true : false + } + } + return next() + } + + const testTenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + if (ctx.headers['tenant-id']) { + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.get( + ctx.headers['tenant-id'] as string + ) + + if (tenant) { tenantApiSignatureResult = { - tenant: result.tenant, - isOperator: result.isOperator ? true : false + tenant, + isOperator: tenant.apiSecret === this.config.adminApiSecret } + } else { + ctx.throw(401, 'Unauthorized') } - return next() - }) + } + return next() } + // For tests, we still need to get the tenant in the middleware, but + // we don't need to verify the signature nor prevent replay attacks + koa.use( + this.config.env !== 'test' + ? tenantSignatureMiddleware + : testTenantSignatureMiddleware + ) + koa.use( koaMiddleware(this.apolloServer, { context: async (): Promise => { diff --git a/packages/backend/src/asset/model.test.ts b/packages/backend/src/asset/model.test.ts index 461fb043e5..d9464d7844 100644 --- a/packages/backend/src/asset/model.test.ts +++ b/packages/backend/src/asset/model.test.ts @@ -38,6 +38,7 @@ describe('Models', (): void => { beforeEach(async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, liquidityThreshold: BigInt(100) } const assetOrError = await assetService.create(options) diff --git a/packages/backend/src/asset/model.ts b/packages/backend/src/asset/model.ts index 62237fcd20..1dda49c754 100644 --- a/packages/backend/src/asset/model.ts +++ b/packages/backend/src/asset/model.ts @@ -13,6 +13,7 @@ export class Asset extends BaseModel implements LiquidityAccount { // TigerBeetle account 2 byte ledger field representing account's asset public readonly ledger!: number + public readonly tenantId!: string public readonly withdrawalThreshold!: bigint | null diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 6c05b221a2..093187fa49 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -57,6 +57,7 @@ describe('Asset Service', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -80,7 +81,10 @@ describe('Asset Service', (): void => { 'createLiquidityAndLinkedSettlementAccount' ) - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) expect(liquidityAndSettlementSpy).toHaveBeenCalledWith( @@ -100,6 +104,7 @@ describe('Asset Service', (): void => { test('Asset can be created with minimum account withdrawal amount', async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10) } const asset = await assetService.create(options) @@ -113,7 +118,7 @@ describe('Asset Service', (): void => { }) test('Cannot create duplicate asset', async (): Promise => { - const options = randomAsset() + const options = { ...randomAsset(), tenantId: Config.operatorTenantId } await expect(assetService.create(options)).resolves.toMatchObject(options) await expect(assetService.create(options)).resolves.toEqual( AssetError.DuplicateAsset @@ -123,7 +128,8 @@ describe('Asset Service', (): void => { test('Cannot create asset with scale > 255', async (): Promise => { const options = { code: 'ABC', - scale: 256 + scale: 256, + tenantId: Config.operatorTenantId } await expect(assetService.create(options)).rejects.toThrow( CheckViolationError @@ -133,7 +139,10 @@ describe('Asset Service', (): void => { describe('get', (): void => { test('Can get asset by id', async (): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) await expect(assetService.get(asset.id)).resolves.toEqual(asset) }) @@ -161,6 +170,7 @@ describe('Asset Service', (): void => { beforeEach(async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -186,6 +196,7 @@ describe('Asset Service', (): void => { }): Promise => { const asset = await assetService.update({ id: assetId, + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -198,10 +209,29 @@ describe('Asset Service', (): void => { } ) + test('Cannot update asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.update({ + id: asset.id, + tenantId: uuid(), + withdrawalThreshold: BigInt(10), + liquidityThreshold: null + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) + test('Cannot update unknown asset', async (): Promise => { await expect( assetService.update({ id: uuid(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: null }) @@ -213,7 +243,11 @@ describe('Asset Service', (): void => { getPageTests({ createModel: () => createAsset(deps), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder) + assetService.getPage({ + pagination, + sortOrder, + tenantId: Config.operatorTenantId + }) }) }) @@ -221,7 +255,10 @@ describe('Asset Service', (): void => { test('returns all assets', async (): Promise => { const assets: (Asset | AssetError)[] = [] for (let i = 0; i < 3; i++) { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assets.push(asset) } @@ -235,12 +272,16 @@ describe('Asset Service', (): void => { describe('delete', (): void => { test('Can delete asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -248,18 +289,26 @@ describe('Asset Service', (): void => { }) test('Can delete and restore asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const { code, scale } = newAsset const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) - const restoredAsset = await assetService.create({ code, scale }) + const restoredAsset = await assetService.create({ + code, + scale, + tenantId: newAsset.tenantId + }) assert.ok(!isAssetError(restoredAsset)) expect(restoredAsset.id).toEqual(newAssetId) expect(restoredAsset.code).toEqual(code) @@ -268,7 +317,10 @@ describe('Asset Service', (): void => { }) test('Cannot delete in use asset (wallet)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id @@ -280,12 +332,19 @@ describe('Asset Service', (): void => { assert.ok(!isWalletAddressError(walletAddress)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) test('Cannot delete in use asset (peer)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id @@ -310,9 +369,30 @@ describe('Asset Service', (): void => { assert.ok(!isPeerError(peer)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) + + test('Cannot delete asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.delete({ + id: asset.id, + tenantId: uuid(), + deletedAt: new Date() + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) }) }) @@ -352,6 +432,7 @@ describe('Asset Service using Cache', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -380,6 +461,7 @@ describe('Asset Service using Cache', (): void => { const spyCacheUpdateSet = jest.spyOn(assetCache, 'set') const assetUpdate = await assetService.update({ id: asset.id, + tenantId: asset.tenantId, withdrawalThreshold, liquidityThreshold }) @@ -400,6 +482,7 @@ describe('Asset Service using Cache', (): void => { // Delete the asset, and ensure it is not cached: const deletedAsset = await assetService.delete({ id: asset.id, + tenantId: asset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -409,4 +492,26 @@ describe('Asset Service using Cache', (): void => { } ) }) + + test('cannot get asset from cache if incorrect tenantId', async (): Promise => { + const options = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + const spyCacheSet = jest.spyOn(assetCache, 'set') + + const asset = await assetService.create(options) + assert.ok(!isAssetError(asset)) + + expect(spyCacheSet).toHaveBeenCalledWith( + asset.id, + expect.objectContaining(options) + ) + + const spyCacheGet = jest.spyOn(assetCache, 'get') + await expect(assetService.get(asset.id, uuid())).resolves.toEqual(undefined) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(asset.id) + }) }) diff --git a/packages/backend/src/asset/service.ts b/packages/backend/src/asset/service.ts index 5dbe8b63c1..9d68d1f4f7 100644 --- a/packages/backend/src/asset/service.ts +++ b/packages/backend/src/asset/service.ts @@ -15,27 +15,43 @@ export interface AssetOptions { } export interface CreateOptions extends AssetOptions { + tenantId: string withdrawalThreshold?: bigint liquidityThreshold?: bigint } export interface UpdateOptions { id: string + tenantId: string withdrawalThreshold: bigint | null liquidityThreshold: bigint | null } + export interface DeleteOptions { id: string + tenantId: string deletedAt: Date } +interface GetByCodeAndScaleOptions { + code: string + scale: number + tenantId: string +} + +interface GetPageOptions { + pagination?: Pagination + sortOrder?: SortOrder + tenantId?: string +} + export interface AssetService { create(options: CreateOptions): Promise update(options: UpdateOptions): Promise delete(options: DeleteOptions): Promise - get(id: string): Promise - getByCodeAndScale(code: string, scale: number): Promise - getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise + get(id: string, tenantId?: string): Promise + getByCodeAndScale(options: GetByCodeAndScaleOptions): Promise + getPage(options: GetPageOptions): Promise getAll(): Promise } @@ -65,18 +81,22 @@ export async function createAssetService({ create: (options) => createAsset(deps, options), update: (options) => updateAsset(deps, options), delete: (options) => deleteAsset(deps, options), - get: (id) => getAsset(deps, id), - getByCodeAndScale: (code, scale) => - getAssetByCodeAndScale(deps, code, scale), - getPage: (pagination?, sortOrder?) => - getAssetsPage(deps, pagination, sortOrder), + get: (id, tenantId) => getAsset(deps, id, tenantId), + getByCodeAndScale: (options) => getAssetByCodeAndScale(deps, options), + getPage: (options) => getAssetsPage(deps, options), getAll: () => getAll(deps) } } async function createAsset( deps: ServiceDependencies, - { code, scale, withdrawalThreshold, liquidityThreshold }: CreateOptions + { + code, + scale, + withdrawalThreshold, + liquidityThreshold, + tenantId + }: CreateOptions ): Promise { try { // check if exists but deleted | by code-scale @@ -84,6 +104,7 @@ async function createAsset( .whereNotNull('deletedAt') .where('code', code) .andWhere('scale', scale) + .andWhere('tenantId', tenantId) .first() if (deletedAsset) { @@ -105,6 +126,7 @@ async function createAsset( const asset = await Asset.query(trx).insertAndFetch({ code, scale, + tenantId, withdrawalThreshold, liquidityThreshold }) @@ -126,14 +148,18 @@ async function createAsset( async function updateAsset( deps: ServiceDependencies, - { id, withdrawalThreshold, liquidityThreshold }: UpdateOptions + { id, tenantId, withdrawalThreshold, liquidityThreshold }: UpdateOptions ): Promise { if (!deps.knex) { throw new Error('Knex undefined') } try { const asset = await Asset.query(deps.knex) - .patchAndFetchById(id, { withdrawalThreshold, liquidityThreshold }) + .where({ tenantId }) + .patchAndFetchById(id, { + withdrawalThreshold, + liquidityThreshold + }) .throwIfNotFound() await deps.assetCache.set(id, asset) @@ -149,12 +175,20 @@ async function updateAsset( // soft delete async function deleteAsset( deps: ServiceDependencies, - { id, deletedAt }: DeleteOptions + options: DeleteOptions ): Promise { + const { id, tenantId, deletedAt } = options if (!deps.knex) { throw new Error('Knex undefined') } + // Check the correct tenant is requesting delete operation + const existingAsset = await getAsset(deps, id, tenantId) + + if (!existingAsset) { + return AssetError.UnknownAsset + } + await deps.assetCache.delete(id) try { // return error in case there is a peer or wallet address using the asset @@ -182,12 +216,22 @@ async function deleteAsset( async function getAsset( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { const inMem = await deps.assetCache.get(id) - if (inMem) return inMem + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + const asset = await query.findById(id) - const asset = await Asset.query(deps.knex).whereNull('deletedAt').findById(id) if (asset) await deps.assetCache.set(asset.id, asset) return asset @@ -195,24 +239,27 @@ async function getAsset( async function getAssetByCodeAndScale( deps: ServiceDependencies, - code: string, - scale: number + options: GetByCodeAndScaleOptions ): Promise { - return await Asset.query(deps.knex) - .where({ code: code, scale: scale }) - .first() + return await Asset.query(deps.knex).where(options).first() } async function getAssetsPage( deps: ServiceDependencies, - pagination?: Pagination, - sortOrder?: SortOrder + options: GetPageOptions ): Promise { - return await Asset.query(deps.knex) - .whereNull('deletedAt') - .getPage(pagination, sortOrder) + const { tenantId, pagination, sortOrder } = options + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + return await query.getPage(pagination, sortOrder) } +// This used in auto-peering, what to do? async function getAll(deps: ServiceDependencies): Promise { return await Asset.query(deps.knex).whereNull('deletedAt') } diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 0d51cc501f..8b972cfe99 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -197,7 +197,8 @@ export const Config = { 5 ), localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000), - operatorTenantId: envString('OPERATOR_TENANT_ID') + operatorTenantId: envString('OPERATOR_TENANT_ID'), + dbSchema: undefined as string | undefined } function parseRedisTlsConfig( diff --git a/packages/backend/src/graphql/resolvers/asset.test.ts b/packages/backend/src/graphql/resolvers/asset.test.ts index 7968de3f98..7768d29a12 100644 --- a/packages/backend/src/graphql/resolvers/asset.test.ts +++ b/packages/backend/src/graphql/resolvers/asset.test.ts @@ -132,7 +132,7 @@ describe('Asset Resolvers', (): void => { test('Returns error for duplicate asset', async (): Promise => { const input = randomAsset() - await assetService.create(input) + await assetService.create({ ...input, tenantId: Config.operatorTenantId }) expect.assertions(2) try { @@ -218,6 +218,7 @@ describe('Asset Resolvers', (): void => { test('Can get an asset', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -283,6 +284,7 @@ describe('Asset Resolvers', (): void => { test('Can get an asset by code and scale', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -349,7 +351,10 @@ describe('Asset Resolvers', (): void => { { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Sending }, { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Receiving } ])('Can get an asset with fee of %p', async (fee): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) let expectedFee = null @@ -469,6 +474,7 @@ describe('Asset Resolvers', (): void => { createModel: () => assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) as Promise, @@ -480,6 +486,7 @@ describe('Asset Resolvers', (): void => { for (let i = 0; i < 2; i++) { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -620,6 +627,7 @@ describe('Asset Resolvers', (): void => { beforeEach(async (): Promise => { asset = (await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold })) as AssetModel diff --git a/packages/backend/src/graphql/resolvers/asset.ts b/packages/backend/src/graphql/resolvers/asset.ts index 50638d721c..c6862aa9b8 100644 --- a/packages/backend/src/graphql/resolvers/asset.ts +++ b/packages/backend/src/graphql/resolvers/asset.ts @@ -7,7 +7,7 @@ import { } from '../generated/graphql' import { Asset } from '../../asset/model' import { errorToCode, errorToMessage, isAssetError } from '../../asset/errors' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { feeToGraphql } from './fee' @@ -15,37 +15,42 @@ import { Fee, FeeType } from '../../fee/model' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getAssets: QueryResolvers['assets'] = async ( - parent, - args, - ctx -): Promise => { - const assetService = await ctx.container.use('assetService') - const { sortOrder, ...pagination } = args - const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc - const assets = await assetService.getPage(pagination, order) - const pageInfo = await getPageInfo({ - getPage: (pagination: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder), - page: assets, - sortOrder: order - }) - return { - pageInfo, - edges: assets.map((asset: Asset) => ({ - cursor: asset.id, - node: assetToGraphql(asset) - })) +export const getAssets: QueryResolvers['assets'] = + async (parent, args, ctx): Promise => { + const assetService = await ctx.container.use('assetService') + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const assets = await assetService.getPage({ + pagination, + sortOrder: order, + tenantId: ctx.tenant.id + }) + const pageInfo = await getPageInfo({ + getPage: (pagination: Pagination, sortOrder?: SortOrder) => + assetService.getPage({ + pagination, + sortOrder, + tenantId: ctx.tenant.id + }), + page: assets, + sortOrder: order + }) + return { + pageInfo, + edges: assets.map((asset: Asset) => ({ + cursor: asset.id, + node: assetToGraphql(asset) + })) + } } -} -export const getAsset: QueryResolvers['asset'] = async ( +export const getAsset: QueryResolvers['asset'] = async ( parent, args, ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.get(args.id) + const asset = await assetService.get(args.id, ctx.tenant.id) if (!asset) { throw new GraphQLError('Asset not found', { extensions: { @@ -56,21 +61,28 @@ export const getAsset: QueryResolvers['asset'] = async ( return assetToGraphql(asset) } -export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = +export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = async (parent, args, ctx): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.getByCodeAndScale(args.code, args.scale) + const asset = await assetService.getByCodeAndScale({ + code: args.code, + scale: args.scale, + tenantId: ctx.tenant.id + }) return asset ? assetToGraphql(asset) : null } -export const createAsset: MutationResolvers['createAsset'] = +export const createAsset: MutationResolvers['createAsset'] = async ( parent, args, ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const assetOrError = await assetService.create(args.input) + const assetOrError = await assetService.create({ + ...args.input, + tenantId: ctx.tenant.id + }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { extensions: { @@ -83,7 +95,7 @@ export const createAsset: MutationResolvers['createAsset'] = } } -export const updateAsset: MutationResolvers['updateAsset'] = +export const updateAsset: MutationResolvers['updateAsset'] = async ( parent, args, @@ -93,7 +105,8 @@ export const updateAsset: MutationResolvers['updateAsset'] = const assetOrError = await assetService.update({ id: args.input.id, withdrawalThreshold: args.input.withdrawalThreshold ?? null, - liquidityThreshold: args.input.liquidityThreshold ?? null + liquidityThreshold: args.input.liquidityThreshold ?? null, + tenantId: ctx.tenant.id }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { @@ -107,7 +120,7 @@ export const updateAsset: MutationResolvers['updateAsset'] = } } -export const getAssetSendingFee: AssetResolvers['sendingFee'] = +export const getAssetSendingFee: AssetResolvers['sendingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -119,7 +132,7 @@ export const getAssetSendingFee: AssetResolvers['sendingFee'] = return feeToGraphql(fee) } -export const getAssetReceivingFee: AssetResolvers['receivingFee'] = +export const getAssetReceivingFee: AssetResolvers['receivingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -131,7 +144,7 @@ export const getAssetReceivingFee: AssetResolvers['receivingFee'] return feeToGraphql(fee) } -export const getFees: AssetResolvers['fees'] = async ( +export const getFees: AssetResolvers['fees'] = async ( parent, args, ctx @@ -159,7 +172,7 @@ export const getFees: AssetResolvers['fees'] = async ( } } -export const deleteAsset: MutationResolvers['deleteAsset'] = +export const deleteAsset: MutationResolvers['deleteAsset'] = async ( _, args, @@ -168,6 +181,7 @@ export const deleteAsset: MutationResolvers['deleteAsset'] = const assetService = await ctx.container.use('assetService') const assetOrError = await assetService.delete({ id: args.input.id, + tenantId: ctx.tenant.id, deletedAt: new Date() }) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c0be42bb28..8ab7092dcd 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -107,6 +107,7 @@ export function initIocContainer( directory: './', tableName: 'knex_migrations' }, + searchPath: config.dbSchema, log: { warn(message) { logger.warn(message) @@ -128,6 +129,9 @@ export function initIocContainer( 'text', BigInt ) + if (config.dbSchema) { + await db.raw(`CREATE SCHEMA IF NOT EXISTS "${config.dbSchema}"`) + } return db }) container.singleton('redis', async (deps): Promise => { diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index 18be4c5541..bd60935d40 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -300,9 +300,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = assetIds[cursor] else pagination.after = assetIds[cursor] } - const page = await assetService.getPage(pagination) + const page = await assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }) const pageInfo = await getPageInfo({ - getPage: (pagination) => assetService.getPage(pagination), + getPage: (pagination) => + assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }), page }) expect(pageInfo).toEqual({ diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index abe2c1917d..7b328bae0b 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -197,7 +197,7 @@ export async function getTenantFromApiSignature( } const tenantService = await ctx.container.use('tenantService') - const tenantId = headers['tenant-id'] + const tenantId = headers['tenant-id'] as string const tenant = tenantId ? await tenantService.get(tenantId) : undefined if (!tenant) return undefined diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 3a7234b0de..06837c18fe 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -7,7 +7,7 @@ import { AppServices } from '../app' import { initIocContainer } from '..' import { createTestApp, TestContainer } from '../tests/app' import { TenantService } from './service' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { truncateTables } from '../tests/tableManager' import { Tenant } from './model' import { getPageTests } from '../shared/baseModel.test' @@ -15,24 +15,31 @@ import { Pagination, SortOrder } from '../shared/baseModel' import { createTenant } from '../tests/tenant' import { CacheDataStore } from '../middleware/cache/data-stores' import { AuthServiceClient } from '../auth-service-client/client' +import { withConfigOverride } from '../tests/helpers' describe('Tenant Service', (): void => { let deps: IocContract + let config: IAppConfig let appContainer: TestContainer let tenantService: TenantService let knex: Knex + const dbSchema = 'tenant_service_test_schema' let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { - deps = initIocContainer(Config) + deps = initIocContainer({ + ...Config, + dbSchema + }) appContainer = await createTestApp(deps) tenantService = await deps.use('tenantService') knex = await deps.use('knex') + config = await deps.use('config') authServiceClient = await deps.use('authServiceClient') }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(knex, true, dbSchema) }) afterAll(async (): Promise => { @@ -333,90 +340,83 @@ describe('Tenant Service', (): void => { }) describe('Tenant Service using cache', (): void => { - let deps: IocContract - let appContainer: TestContainer - let tenantService: TenantService let tenantCache: CacheDataStore let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { - deps = initIocContainer({ - ...Config, - localCacheDuration: 5_000 // 5-second default. - }) - appContainer = await createTestApp(deps) - tenantService = await deps.use('tenantService') tenantCache = await deps.use('tenantCache') authServiceClient = await deps.use('authServiceClient') }) - afterEach(async (): Promise => { - await truncateTables(appContainer.knex) - }) - - afterAll(async (): Promise => { - await appContainer.shutdown() - }) - describe('create, update, and retrieve tenant using cache', (): void => { - test('Tenant can be created, updated, and fetched', async (): Promise => { - const createOptions = { - email: faker.internet.email(), - publicName: faker.company.name(), - apiSecret: 'test-api-secret', - idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' - } - - jest - .spyOn(authServiceClient.tenant, 'create') - .mockImplementation(async () => undefined) - - const spyCacheSet = jest.spyOn(tenantCache, 'set') - const tenant = await tenantService.create(createOptions) - expect(tenant).toMatchObject({ - ...createOptions, - id: tenant.id - }) - - // Ensure that the cache was set for create - expect(spyCacheSet).toHaveBeenCalledTimes(1) - - const spyCacheGet = jest.spyOn(tenantCache, 'get') - await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) - - expect(spyCacheGet).toHaveBeenCalledTimes(1) - expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) - - const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') - jest - .spyOn(authServiceClient.tenant, 'update') - .mockImplementation(async () => undefined) - const updatedTenant = await tenantService.update({ - id: tenant.id, - apiSecret: 'test-api-secret-2' - }) - - await expect(tenantService.get(tenant.id)).resolves.toEqual( - updatedTenant + test( + 'Tenant can be created, updated, and fetched', + withConfigOverride( + () => config, + { localCacheDuration: 5_000 }, + async (): Promise => { + const createOptions = { + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: 'test-api-secret', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + + const spyCacheSet = jest.spyOn(tenantCache, 'set') + const tenant = await tenantService.create(createOptions) + expect(tenant).toMatchObject({ + ...createOptions, + id: tenant.id + }) + + // Ensure that the cache was set for create + expect(spyCacheSet).toHaveBeenCalledTimes(1) + + const spyCacheGet = jest.spyOn(tenantCache, 'get') + await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) + + const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + const updatedTenant = await tenantService.update({ + id: tenant.id, + apiSecret: 'test-api-secret-2' + }) + + await expect(tenantService.get(tenant.id)).resolves.toEqual( + updatedTenant + ) + + // Ensure that cache was set for update + expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) + expect(spyCacheUpdateSet).toHaveBeenCalledWith( + tenant.id, + updatedTenant + ) + + const spyCacheDelete = jest.spyOn(tenantCache, 'delete') + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + await tenantService.delete(tenant.id) + + await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() + + // Ensure that cache was set for deletion + expect(spyCacheDelete).toHaveBeenCalledTimes(1) + expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) + } ) - - // Ensure that cache was set for update - expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) - expect(spyCacheUpdateSet).toHaveBeenCalledWith(tenant.id, updatedTenant) - - const spyCacheDelete = jest.spyOn(tenantCache, 'delete') - jest - .spyOn(authServiceClient.tenant, 'delete') - .mockImplementation(async () => undefined) - await tenantService.delete(tenant.id) - - await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() - - // Ensure that cache was set for deletion - expect(spyCacheDelete).toHaveBeenCalledTimes(1) - expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) - }) + ) }) }) }) diff --git a/packages/backend/src/tests/app.ts b/packages/backend/src/tests/app.ts index cbe82b4704..3642de6c1a 100644 --- a/packages/backend/src/tests/app.ts +++ b/packages/backend/src/tests/app.ts @@ -79,7 +79,8 @@ export const createTestApp = async ( const authLink = setContext((_, { headers }) => { return { headers: { - ...headers + ...headers, + 'tenant-id': config.operatorTenantId } } }) diff --git a/packages/backend/src/tests/asset.ts b/packages/backend/src/tests/asset.ts index daab8992be..d77fd1655b 100644 --- a/packages/backend/src/tests/asset.ts +++ b/packages/backend/src/tests/asset.ts @@ -27,8 +27,13 @@ export async function createAsset( deps: IocContract, options?: AssetOptions ): Promise { + const config = await deps.use('config') const assetService = await deps.use('assetService') - const assetOrError = await assetService.create(options || randomAsset()) + const createOptions = options || randomAsset() + const assetOrError = await assetService.create({ + ...createOptions, + tenantId: config.operatorTenantId + }) if (isAssetError(assetOrError)) { throw assetOrError } diff --git a/packages/backend/src/tests/tableManager.ts b/packages/backend/src/tests/tableManager.ts index 26f07d5d2d..9467127684 100644 --- a/packages/backend/src/tests/tableManager.ts +++ b/packages/backend/src/tests/tableManager.ts @@ -10,21 +10,28 @@ export async function truncateTable( export async function truncateTables( knex: Knex, - ignoreTables = [ + truncateTenants = false, + dbSchema?: string +): Promise { + const ignoreTables = [ 'knex_migrations', 'knex_migrations_lock', 'knex_migrations_backend', - 'knex_migrations_backend_lock' + 'knex_migrations_backend_lock', + ...(truncateTenants ? [] : ['tenants']) // So we don't delete operator tenant ] -): Promise { - const tables = await getTables(knex, ignoreTables) + const tables = await getTables(knex, dbSchema, ignoreTables) const RAW = `TRUNCATE TABLE "${tables}" RESTART IDENTITY` await knex.raw(RAW) } -async function getTables(knex: Knex, ignoredTables: string[]): Promise { +async function getTables( + knex: Knex, + dbSchema: string = 'public', + ignoredTables: string[] +): Promise { const result = await knex.raw( - "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'" + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='${dbSchema}'` ) return result.rows .map((val: { tablename: string }) => {