diff --git a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts index e4a934def3..5ab802e182 100644 --- a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts @@ -15,6 +15,10 @@ if (!process.env.IDP_SECRET) { throw new Error('Environment variable IDP_SECRET is required') } +if (!process.env.OPERATOR_TENANT_ID) { + throw new Error('Environment variable OPERATOR_TENANT_ID is required') +} + export const CONFIG: Config = { seed: parse( readFileSync( @@ -26,5 +30,6 @@ export const CONFIG: Config = { testnetAutoPeerUrl: process.env.TESTNET_AUTOPEER_URL ?? '', authServerDomain: process.env.AUTH_SERVER_DOMAIN || 'http://localhost:3006', graphqlUrl: process.env.GRAPHQL_URL, - idpSecret: process.env.IDP_SECRET + idpSecret: process.env.IDP_SECRET, + operatorTenantId: process.env.OPERATOR_TENANT_ID } diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 46dad2dedb..4bc9d24de2 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -373,6 +373,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -1263,6 +1265,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1496,6 +1499,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -2417,6 +2422,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js b/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js new file mode 100644 index 0000000000..a3f21e9904 --- /dev/null +++ b/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('walletAddresses', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "walletAddresses" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('walletAddresses', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('walletAddresses', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e7c22743d4..aa46528a58 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -70,7 +70,8 @@ import { applyMiddleware } from 'graphql-middleware' import { Redis } from 'ioredis' import { idempotencyGraphQLMiddleware, - lockGraphQLMutationMiddleware + lockGraphQLMutationMiddleware, + setForTenantIdGraphQLMutationMiddleware } from './graphql/middleware' import { createRedisDataStore } from './middleware/cache/data-stores/redis' import { createRedisLock } from './middleware/lock/redis' @@ -226,6 +227,10 @@ export interface TenantedApolloContext extends ApolloContext { isOperator: boolean } +export interface ForTenantIdContext extends TenantedApolloContext { + forTenantId?: string +} + export interface AppServices { logger: Promise telemetry: Promise @@ -338,7 +343,8 @@ export class App { ), idempotencyGraphQLMiddleware( createRedisDataStore(redis, this.config.graphQLIdempotencyKeyTtlMs) - ) + ), + setForTenantIdGraphQLMutationMiddleware() ) // Setup Armor diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 093187fa49..5fd3042012 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -327,6 +327,7 @@ describe('Asset Service', (): void => { // make sure there is at least 1 wallet address using asset const walletAddress = walletAddressService.create({ url: 'https://alice.me/.well-known/pay', + tenantId: Config.operatorTenantId, assetId: newAssetId }) assert.ok(!isWalletAddressError(walletAddress)) diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 1745966aa2..db39137300 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -594,7 +594,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** fees after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** fees after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -2171,6 +2171,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "url", "description": "Wallet address URL. This cannot be changed.", @@ -6318,7 +6330,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** assets after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** assets after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -6931,6 +6943,18 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null } ], "type": { @@ -8410,7 +8434,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** quotes after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** quotes after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -8469,6 +8493,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Tenant ID of the wallet address.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "url", "description": "Wallet Address URL.", @@ -8515,7 +8551,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** keys after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** keys after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 46dad2dedb..4bc9d24de2 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -373,6 +373,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -1263,6 +1265,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1496,6 +1499,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -2417,6 +2422,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/backend/src/graphql/middleware/index.ts b/packages/backend/src/graphql/middleware/index.ts index d8e490e4c4..f2b7d0ff53 100644 --- a/packages/backend/src/graphql/middleware/index.ts +++ b/packages/backend/src/graphql/middleware/index.ts @@ -1,9 +1,14 @@ import { GraphQLError } from 'graphql' import { IMiddleware } from 'graphql-middleware' -import { ApolloContext } from '../../app' +import { + ApolloContext, + ForTenantIdContext, + TenantedApolloContext +} from '../../app' import { CacheDataStore } from '../../middleware/cache/data-stores' import { lockMiddleware, Lock } from '../../middleware/lock' import { cacheMiddleware } from '../../middleware/cache' +import { validateTenantMiddleware } from '../../middleware/tenant' export function lockGraphQLMutationMiddleware(lock: Lock): { Mutation: IMiddleware @@ -46,3 +51,17 @@ export function idempotencyGraphQLMiddleware( } } } + +export function setForTenantIdGraphQLMutationMiddleware(): { + Mutation: IMiddleware +} { + return { + Mutation: async (resolve, root, args, context, info) => { + return validateTenantMiddleware({ + deps: { context }, + next: () => resolve(root, args, context, info), + tenantIdInput: args?.input?.tenantId + }) + } + } +} diff --git a/packages/backend/src/graphql/resolvers/combined_payments.test.ts b/packages/backend/src/graphql/resolvers/combined_payments.test.ts index 50af7a5d4c..0f2550a1b9 100644 --- a/packages/backend/src/graphql/resolvers/combined_payments.test.ts +++ b/packages/backend/src/graphql/resolvers/combined_payments.test.ts @@ -50,6 +50,7 @@ describe('Payment', (): void => { test('Can get payments', async (): Promise => { const { id: outWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -68,6 +69,7 @@ describe('Payment', (): void => { }) const { id: inWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const incomingPayment = await createIncomingPayment(deps, { @@ -146,6 +148,7 @@ describe('Payment', (): void => { test('Can filter payments by type and wallet address', async (): Promise => { const { id: outWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -168,6 +171,7 @@ describe('Payment', (): void => { }) const { id: outWalletAddressId2 } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) await createOutgoingPayment(deps, { diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts index 0db5f7cf66..56c0c74ddd 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts @@ -55,8 +55,12 @@ describe('Incoming Payment Resolver', (): void => { describe('Wallet address incoming payments', (): void => { beforeEach(async (): Promise => { - walletAddressId = (await createWalletAddress(deps, { assetId: asset.id })) - .id + walletAddressId = ( + await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, + assetId: asset.id + }) + ).id }) getPageTests({ @@ -106,6 +110,7 @@ describe('Incoming Payment Resolver', (): void => { async ({ metadata, expiresAt, withAmount }): Promise => { const incomingAmount = withAmount ? amount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createIncomingPayment(deps, { @@ -305,6 +310,7 @@ describe('Incoming Payment Resolver', (): void => { } beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) payment = await createPayment({ walletAddressId, metadata }) @@ -462,6 +468,7 @@ describe('Incoming Payment Resolver', (): void => { async ({ metadata }): Promise => { const incomingAmount = amount ? amount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createIncomingPayment(deps, { diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index e0968b27e1..84df10f1fc 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -1015,6 +1015,7 @@ describe('Liquidity Resolvers', (): void => { beforeEach(async (): Promise => { walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) @@ -1747,7 +1748,9 @@ describe('Liquidity Resolvers', (): void => { let payment: OutgoingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { walletAddressId, @@ -2157,7 +2160,9 @@ describe('Liquidity Resolvers', (): void => { let outgoingPayment: OutgoingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { walletAddressId, diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts index dd41c4eb89..00b4e0aa60 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts @@ -99,6 +99,7 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { walletAddressId = ( await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) ).id @@ -135,13 +136,16 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const firstReceiverWalletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const secondWalletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const secondReceiverWalletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -326,6 +330,7 @@ describe('OutgoingPayment Resolvers', (): void => { const grantId = uuid() const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -365,6 +370,7 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) payment = await createPayment({ walletAddressId, metadata }) @@ -553,6 +559,7 @@ describe('OutgoingPayment Resolvers', (): void => { test('success (metadata)', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createPayment({ walletAddressId, metadata }) @@ -689,6 +696,7 @@ describe('OutgoingPayment Resolvers', (): void => { test('create', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createPayment({ walletAddressId: walletAddress.id }) @@ -840,6 +848,7 @@ describe('OutgoingPayment Resolvers', (): void => { let payment: OutgoingPaymentModel beforeEach(async () => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -956,6 +965,7 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { walletAddressId = ( await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) ).id diff --git a/packages/backend/src/graphql/resolvers/quote.test.ts b/packages/backend/src/graphql/resolvers/quote.test.ts index dcb703edc6..5132b5292b 100644 --- a/packages/backend/src/graphql/resolvers/quote.test.ts +++ b/packages/backend/src/graphql/resolvers/quote.test.ts @@ -71,6 +71,7 @@ describe('Quote Resolvers', (): void => { describe('Query.quote', (): void => { test('success', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const quote = await createWalletAddressQuote(walletAddressId) @@ -189,6 +190,7 @@ describe('Quote Resolvers', (): void => { `('$type', async ({ withAmount, receiveAmount }): Promise => { const amount = withAmount ? debitAmount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const input = { @@ -300,6 +302,7 @@ describe('Quote Resolvers', (): void => { beforeEach(async (): Promise => { walletAddressId = ( await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) ).id diff --git a/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts b/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts index fb2f133e0d..fc4400f9af 100644 --- a/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts +++ b/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts @@ -51,7 +51,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('Create Wallet Address Keys', (): void => { test('Can create wallet address key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input: CreateWalletAddressKeyInput = { walletAddressId: walletAddress.id, @@ -104,9 +106,10 @@ describe('Wallet Address Key Resolvers', (): void => { revoked: false }) }) - test('Cannot add duplicate key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input: CreateWalletAddressKeyInput = { walletAddressId: walletAddress.id, @@ -172,7 +175,9 @@ describe('Wallet Address Key Resolvers', (): void => { throw new Error('unexpected') }) - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input = { walletAddressId: walletAddress.id, @@ -230,7 +235,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('Revoke key', (): void => { test('Can revoke a key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const key = await walletAddressKeyService.create({ walletAddressId: walletAddress.id, @@ -334,7 +341,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('List Wallet Address Keys', (): void => { let walletAddressId: string beforeEach(async (): Promise => { - walletAddressId = (await createWalletAddress(deps)).id + walletAddressId = ( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ).id }) getPageTests({ getClient: () => appContainer.apolloClient, diff --git a/packages/backend/src/graphql/resolvers/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index 8f559d8a40..88ae1d78d8 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -35,21 +35,27 @@ import { import { getPageTests } from './page.test' import { WalletAddressAdditionalProperty } from '../../open_payments/wallet_address/additional_property/model' import { GraphQLErrorCode } from '../errors' +import { AssetService } from '../../asset/service' +import { faker } from '@faker-js/faker' +import { Tenant } from '../../tenants/model' describe('Wallet Address Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer let knex: Knex let walletAddressService: WalletAddressService + let assetService: AssetService beforeAll(async (): Promise => { - deps = await initIocContainer({ + deps = initIocContainer({ ...Config, - localCacheDuration: 0 + localCacheDuration: 0, + adminApiSecret: '123' //to force not being an operator. }) appContainer = await createTestApp(deps) knex = appContainer.knex walletAddressService = await deps.use('walletAddressService') + assetService = await deps.use('assetService') }) afterEach(async (): Promise => { @@ -69,6 +75,7 @@ describe('Wallet Address Resolvers', (): void => { asset = await createAsset(deps) input = { assetId: asset.id, + tenantId: Config.operatorTenantId, url: 'https://alice.me/.well-known/pay' } }) @@ -306,13 +313,64 @@ describe('Wallet Address Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant create', async (): Promise => { + const badInputData = { + tenantId: 'ae4950b6-3e1b-4e50-ad24-25c065bdd3a9', + assetId: input.assetId, + url: input.url + } + try { + expect.assertions(2) + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation CreateWalletAddress( + $badInputData: CreateWalletAddressInput! + ) { + createWalletAddress(input: $badInputData) { + walletAddress { + id + asset { + code + scale + } + } + } + } + `, + variables: { + badInputData + } + }) + .then((query): CreateWalletAddressMutationResponse => { + if (query.data) { + return query.data.createWalletAddress + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Assignment to the specified tenant is not permitted', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.BadUserInput + }) + }) + ) + } + }) }) describe('Update Wallet Address', (): void => { let walletAddress: WalletAddressModel beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) test('Can update a wallet address', async (): Promise => { @@ -426,6 +484,7 @@ describe('Wallet Address Resolvers', (): void => { }) test('New additional properties override previous additional properties', async (): Promise => { const createOptions = { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'existingKey', @@ -492,6 +551,7 @@ describe('Wallet Address Resolvers', (): void => { }) test('Updating with empty additional properties deletes existing', async (): Promise => { const createOptions = { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'existingKey', @@ -634,6 +694,68 @@ describe('Wallet Address Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant update', async (): Promise => { + expect.assertions(2) + try { + const tenantOptions = { + apiSecret: 'test-api-secret-new', + publicName: 'test tenant new', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-new' + } + const newTenant = await Tenant.query(knex).insertAndFetch(tenantOptions) + const newAsset = await assetService.create({ + code: 'USD', + scale: 2, + tenantId: newTenant!.id + }) + const newWalletAddress = await walletAddressService.create({ + assetId: (newAsset as Asset).id, + tenantId: newTenant!.id, + url: 'https://alice.me/.well-known/pay-2' + }) + const id = (newWalletAddress as WalletAddressModel).id + + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation UpdateWalletAddress($input: UpdateWalletAddressInput!) { + updateWalletAddress(input: $input) { + walletAddress { + id + status + } + } + } + `, + variables: { + input: { + id, + status: WalletAddressStatus.Inactive + } + } + }) + .then((query): UpdateWalletAddressMutationResponse => { + if (query.data) { + return query.data.updateWalletAddress + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Unknown wallet address', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) }) describe('Wallet Address Queries', (): void => { @@ -655,6 +777,7 @@ describe('Wallet Address Resolvers', (): void => { const additionalProperties = [walletProp01, walletProp02] const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName, createLiquidityAccount: true, additionalProperties @@ -729,6 +852,7 @@ describe('Wallet Address Resolvers', (): void => { 'Can get a wallet address by its url (publicName: $publicName)', async ({ publicName }): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName, createLiquidityAccount: true }) @@ -818,14 +942,17 @@ describe('Wallet Address Resolvers', (): void => { getPageTests({ getClient: () => appContainer.apolloClient, - createModel: () => createWalletAddress(deps), + createModel: () => + createWalletAddress(deps, { tenantId: Config.operatorTenantId }), pagedQuery: 'walletAddresses' }) test('Can get page of wallet addresses', async (): Promise => { const walletAddresses: WalletAddressModel[] = [] for (let i = 0; i < 2; i++) { - walletAddresses.push(await createWalletAddress(deps)) + walletAddresses.push( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ) } walletAddresses.reverse() // Calling the default getPage will result in descending order const query = await appContainer.apolloClient @@ -874,6 +1001,64 @@ describe('Wallet Address Resolvers', (): void => { }) }) }) + + test('Can get page of wallet addresses with tenantId param', async (): Promise => { + const walletAddresses: WalletAddressModel[] = [] + for (let i = 0; i < 2; i++) { + walletAddresses.push( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ) + } + walletAddresses.reverse() // Calling the default getPage will result in descending order + const query = await appContainer.apolloClient + .query({ + query: gql` + query WalletAddresses($tenantId: String) { + walletAddresses(tenantId: $tenantId) { + edges { + node { + id + asset { + code + scale + } + url + publicName + } + cursor + } + } + } + `, + variables: { + tenantId: Config.operatorTenantId + } + }) + .then((query): WalletAddressesConnection => { + if (query.data) { + return query.data.walletAddresses + } else { + throw new Error('Data was empty') + } + }) + + expect(query.edges).toHaveLength(2) + query.edges.forEach((edge, idx) => { + const walletAddress = walletAddresses[idx] + expect(edge.cursor).toEqual(walletAddress.id) + expect(edge.node).toEqual({ + __typename: 'WalletAddress', + id: walletAddress.id, + asset: { + __typename: 'Asset', + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + url: walletAddress.url, + publicName: walletAddress.publicName + }) + }) + }) }) describe('Trigger Wallet Address Events', (): void => { @@ -889,6 +1074,7 @@ describe('Wallet Address Resolvers', (): void => { const withdrawalAmount = BigInt(10) for (let i = 0; i < 3; i++) { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) if (i) { diff --git a/packages/backend/src/graphql/resolvers/wallet_address.ts b/packages/backend/src/graphql/resolvers/wallet_address.ts index d1f7172dab..d4766b521d 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.ts @@ -8,7 +8,7 @@ import { MutationResolvers, WalletAddressStatus } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { WalletAddressError, isWalletAddressError, @@ -23,19 +23,22 @@ import { CreateOptions, UpdateOptions } from '../../open_payments/wallet_address/service' +import { GraphQLErrorCode } from '../errors' -export const getWalletAddresses: QueryResolvers['walletAddresses'] = +export const getWalletAddresses: QueryResolvers['walletAddresses'] = async ( parent, args, ctx ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const { sortOrder, ...pagination } = args + const { tenantId, sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const walletAddresses = await walletAddressService.getPage( pagination, - order + order, + ctx.isOperator ? tenantId : ctx.tenant.id ) const pageInfo = await getPageInfo({ getPage: (pagination: Pagination, sortOrder?: SortOrder) => @@ -52,10 +55,13 @@ export const getWalletAddresses: QueryResolvers['walletAddresses' } } -export const getWalletAddress: QueryResolvers['walletAddress'] = +export const getWalletAddress: QueryResolvers['walletAddress'] = async (parent, args, ctx): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const walletAddress = await walletAddressService.get(args.id) + const walletAddress = await walletAddressService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!walletAddress) { throw new GraphQLError( errorToMessage[WalletAddressError.UnknownWalletAddress], @@ -69,18 +75,21 @@ export const getWalletAddress: QueryResolvers['walletAddress'] = return walletAddressToGraphql(walletAddress) } -export const getWalletAddressByUrl: QueryResolvers['walletAddressByUrl'] = +export const getWalletAddressByUrl: QueryResolvers['walletAddressByUrl'] = async ( parent, args, ctx ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const walletAddress = await walletAddressService.getByUrl(args.url) + const walletAddress = await walletAddressService.getByUrl( + args.url, + ctx.isOperator ? undefined : ctx.tenant.id + ) return walletAddress ? walletAddressToGraphql(walletAddress) : null } -export const createWalletAddress: MutationResolvers['createWalletAddress'] = +export const createWalletAddress: MutationResolvers['createWalletAddress'] = async ( parent, args, @@ -97,8 +106,20 @@ export const createWalletAddress: MutationResolvers['createWallet addProps.push(toAdd) }) + const tenantId = ctx.forTenantId + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + const options: CreateOptions = { assetId: args.input.assetId, + tenantId, additionalProperties: addProps, publicName: args.input.publicName, url: args.input.url @@ -117,7 +138,7 @@ export const createWalletAddress: MutationResolvers['createWallet } } -export const updateWalletAddress: MutationResolvers['updateWalletAddress'] = +export const updateWalletAddress: MutationResolvers['updateWalletAddress'] = async ( parent, args, @@ -125,9 +146,23 @@ export const updateWalletAddress: MutationResolvers['updateWallet ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') const { additionalProperties, ...rest } = args.input + const updateOptions: UpdateOptions = { ...rest } + + const existing = await walletAddressService.get( + updateOptions.id, + ctx.forTenantId + ) + if (!existing) { + throw new GraphQLError(`Unknown wallet address`, { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + if (additionalProperties) { updateOptions.additionalProperties = additionalProperties.map( (property) => { @@ -153,7 +188,7 @@ export const updateWalletAddress: MutationResolvers['updateWallet } } -export const triggerWalletAddressEvents: MutationResolvers['triggerWalletAddressEvents'] = +export const triggerWalletAddressEvents: MutationResolvers['triggerWalletAddressEvents'] = async ( parent, args, @@ -166,15 +201,18 @@ export const triggerWalletAddressEvents: MutationResolvers['trigg } } -export const walletAddressToGraphql = ( +export function walletAddressToGraphql( walletAddress: WalletAddress -): SchemaWalletAddress => ({ - id: walletAddress.id, - url: walletAddress.url, - asset: assetToGraphql(walletAddress.asset), - publicName: walletAddress.publicName ?? undefined, - createdAt: new Date(+walletAddress.createdAt).toISOString(), - status: walletAddress.isActive - ? WalletAddressStatus.Active - : WalletAddressStatus.Inactive -}) +): SchemaWalletAddress { + return { + id: walletAddress.id, + url: walletAddress.url, + asset: assetToGraphql(walletAddress.asset), + publicName: walletAddress.publicName ?? undefined, + createdAt: new Date(+walletAddress.createdAt).toISOString(), + status: walletAddress.isActive + ? WalletAddressStatus.Active + : WalletAddressStatus.Inactive, + tenantId: walletAddress.tenantId + } +} diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index f8286e14d4..b80f5c0df9 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -16,7 +16,7 @@ type Query { after: String "Backward pagination: Cursor (asset ID) to start retrieving assets before this point." before: String - "Foward pagination: Limit the result to the first **n** assets after the `after` cursor." + "Forward pagination: Limit the result to the first **n** assets after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** assets before the `before` cursor." last: Int @@ -70,6 +70,8 @@ type Query { last: Int "Specify the sort order of wallet addresses based on their creation date, either ascending or descending." sortOrder: SortOrder + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): WalletAddressesConnection! "Fetch an Open Payments quote by its ID." @@ -624,7 +626,7 @@ type Asset implements Model { after: String "Backward pagination: Cursor (fee ID) to start retrieving fees before this point." before: String - "Foward pagination: Limit the result to the first **n** fees after the `after` cursor." + "Forward pagination: Limit the result to the first **n** fees after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** fees before the `before` cursor." last: Int @@ -759,7 +761,7 @@ type WalletAddress implements Model { after: String "Backward pagination: Cursor (quote ID) to start retrieving quotes before this point." before: String - "Foward pagination: Limit the result to the first **n** quotes after the `after` cursor." + "Forward pagination: Limit the result to the first **n** quotes after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** quotes before the `before` cursor." last: Int @@ -793,7 +795,7 @@ type WalletAddress implements Model { after: String "Backward pagination: Cursor (wallet address key ID) to start retrieving keys before this point." before: String - "Foward pagination: Limit the result to the first **n** keys after the `after` cursor." + "Forward pagination: Limit the result to the first **n** keys after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** keys before the `before` cursor." last: Int @@ -803,6 +805,9 @@ type WalletAddress implements Model { "Additional properties associated with the wallet address." additionalProperties: [AdditionalProperty] + + "Tenant ID of the wallet address." + tenantId: String } type AdditionalProperty { @@ -1226,6 +1231,8 @@ type CreateReceiverResponse { } input CreateWalletAddressInput { + "Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: ID "Unique identifier of the asset associated with the wallet address. This cannot be changed." assetId: String! "Wallet address URL. This cannot be changed." diff --git a/packages/backend/src/middleware/tenant/index.test.ts b/packages/backend/src/middleware/tenant/index.test.ts new file mode 100644 index 0000000000..38d1288313 --- /dev/null +++ b/packages/backend/src/middleware/tenant/index.test.ts @@ -0,0 +1,13 @@ +import { tenantIdToProceed } from './index' + +describe('Set For Tenant', (): void => { + test('test tenant id to proceed', async (): Promise => { + const sig = 'sig' + const tenantId = 'tenantId' + expect(tenantIdToProceed(false, sig)).toBe(sig) + expect(tenantIdToProceed(false, sig, tenantId)).toBeUndefined() + expect(tenantIdToProceed(false, sig, sig)).toBe(sig) + expect(tenantIdToProceed(true, sig)).toBe(sig) + expect(tenantIdToProceed(true, sig, tenantId)).toBe(tenantId) + }) +}) diff --git a/packages/backend/src/middleware/tenant/index.ts b/packages/backend/src/middleware/tenant/index.ts new file mode 100644 index 0000000000..a90f75670b --- /dev/null +++ b/packages/backend/src/middleware/tenant/index.ts @@ -0,0 +1,48 @@ +import { ForTenantIdContext, TenantedApolloContext } from '../../app' + +type Request = () => Promise + +interface TenantValidateMiddlewareArgs { + deps: { context: TenantedApolloContext } + tenantIdInput: string | undefined + next: Request +} + +export async function validateTenantMiddleware( + args: TenantValidateMiddlewareArgs +): ReturnType { + const { + deps: { context }, + tenantIdInput, + next + } = args + ;(context as ForTenantIdContext).forTenantId = tenantIdToProceed( + context.isOperator, + context.tenant.id, + tenantIdInput + ) + return next() +} + +/** + * The tenantId to use will be determined as follows: + * - When an operator and the {tenantId} is present, return {tenantId} + * - When an operator and {tenantId} is not present, return {signatureTenantId} + * - When NOT an operator and {tenantId} is present, but does not match {signatureTenantId}, return {undefined} + * - Otherwise return {signatureTenantId} + * + * @param isOperator is operator + * @param signatureTenantId the signature tenantId + * @param tenantId the intended tenantId + */ +export function tenantIdToProceed( + isOperator: boolean, + signatureTenantId: string, + tenantId?: string +): string | undefined { + if (isOperator && tenantId) return tenantId + else if (isOperator) return signatureTenantId + return tenantId && tenantId !== signatureTenantId + ? undefined + : signatureTenantId +} diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 26b06dc9da..6f066e4772 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -78,7 +78,9 @@ describe('Auth Middleware', (): void => { Authorization: `GNAP ${token}` } }, - walletAddress: await createWalletAddress(deps) + walletAddress: await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) ctx.container = deps }) diff --git a/packages/backend/src/open_payments/payment/combined/service.test.ts b/packages/backend/src/open_payments/payment/combined/service.test.ts index fddb5efd43..d9907f6084 100644 --- a/packages/backend/src/open_payments/payment/combined/service.test.ts +++ b/packages/backend/src/open_payments/payment/combined/service.test.ts @@ -43,9 +43,13 @@ describe('Combined Payment Service', (): void => { sendAsset = await createAsset(deps) receiveAsset = await createAsset(deps) sendWalletAddressId = ( - await createWalletAddress(deps, { assetId: sendAsset.id }) + await createWalletAddress(deps, { + tenantId: sendAsset.tenantId, + assetId: sendAsset.id + }) ).id receiveWalletAddress = await createWalletAddress(deps, { + tenantId: sendAsset.tenantId, assetId: receiveAsset.id }) }) diff --git a/packages/backend/src/open_payments/payment/incoming/model.test.ts b/packages/backend/src/open_payments/payment/incoming/model.test.ts index e4577af3ad..e25f524449 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.test.ts @@ -42,7 +42,9 @@ describe('Models', (): void => { let incomingPayment: IncomingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) baseUrl = new URL(walletAddress.url).origin incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id, diff --git a/packages/backend/src/open_payments/payment/incoming/routes.test.ts b/packages/backend/src/open_payments/payment/incoming/routes.test.ts index 72835eae3b..3a92e10ee9 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -54,6 +54,7 @@ describe('Incoming Payment Routes', (): void => { expiresAt = new Date(Date.now() + 30_000) asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) baseUrl = new URL(walletAddress.url).origin @@ -127,7 +128,9 @@ describe('Incoming Payment Routes', (): void => { test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( 'returns incoming payment with empty methods if payment state is %s', async (paymentState): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id }) diff --git a/packages/backend/src/open_payments/payment/incoming/service.test.ts b/packages/backend/src/open_payments/payment/incoming/service.test.ts index 6826f46b00..6fa47cef0d 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -53,7 +53,10 @@ describe('Incoming Payment Service', (): void => { beforeEach(async (): Promise => { asset = await createAsset(deps) - const address = await createWalletAddress(deps, { assetId: asset.id }) + const address = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, + assetId: asset.id + }) walletAddressId = address.id client = address.url }) diff --git a/packages/backend/src/open_payments/payment/incoming_remote/service.ts b/packages/backend/src/open_payments/payment/incoming_remote/service.ts index 72c830e5c5..42e3771e47 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/service.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.ts @@ -13,6 +13,7 @@ import { BaseService } from '../../../shared/baseService' import { Amount, serializeAmount } from '../../amount' import { RemoteIncomingPaymentError } from './errors' import { isGrantError } from '../../grant/errors' +import { urlWithoutTenantId } from '../../../shared/utils' interface CreateRemoteIncomingPaymentArgs { walletAddressUrl: string @@ -102,7 +103,7 @@ async function createIncomingPayment( walletAddress.resourceServer ?? new URL(walletAddress.id).origin const grantOptions = { - authServer: walletAddress.authServer, + authServer: urlWithoutTenantId(walletAddress.authServer), accessType: AccessType.IncomingPayment, accessActions: [AccessAction.Create, AccessAction.ReadAll] } @@ -116,7 +117,7 @@ async function createIncomingPayment( try { return await deps.openPaymentsClient.incomingPayment.create( { - url: resourceServerUrl, + url: urlWithoutTenantId(resourceServerUrl), accessToken: grant.accessToken }, { @@ -216,7 +217,7 @@ async function getIncomingPayment( OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError > { const grantOptions = { - authServer: authServerUrl, + authServer: urlWithoutTenantId(authServerUrl), accessType: AccessType.IncomingPayment, accessActions: [AccessAction.ReadAll] } diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts index f30dab4641..270586fc22 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -77,7 +77,10 @@ describe('Outgoing Payment Routes', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) - walletAddress = await createWalletAddress(deps, { assetId: asset.id }) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, + assetId: asset.id + }) baseUrl = new URL(walletAddress.url).origin }) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index b3e98b502c..622fc53ad3 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -272,12 +272,14 @@ describe('OutgoingPaymentService', (): void => { const { id: sendAssetId } = await createAsset(deps, asset) assetId = sendAssetId const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: sendAssetId }) walletAddressId = walletAddress.id client = walletAddress.url const { id: destinationAssetId } = await createAsset(deps, destinationAsset) receiverWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -408,8 +410,12 @@ describe('OutgoingPaymentService', (): void => { let outgoingPayment: OutgoingPayment let otherOutgoingPayment: OutgoingPayment beforeEach(async (): Promise => { - otherSenderWalletAddress = await createWalletAddress(deps, { assetId }) + otherSenderWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, + assetId + }) otherReceiverWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId }) const incomingPayment = await createIncomingPayment(deps, { @@ -974,7 +980,9 @@ describe('OutgoingPaymentService', (): void => { validDestination: false, method: 'ilp' }) - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId + }) const walletAddressUpdated = await WalletAddress.query( knex ).patchAndFetchById(walletAddress.id, { deactivatedAt: new Date() }) diff --git a/packages/backend/src/open_payments/quote/routes.test.ts b/packages/backend/src/open_payments/quote/routes.test.ts index 422759bd73..791b4f48b3 100644 --- a/packages/backend/src/open_payments/quote/routes.test.ts +++ b/packages/backend/src/open_payments/quote/routes.test.ts @@ -77,6 +77,7 @@ describe('Quote Routes', (): void => { scale: debitAmount.assetScale }) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId }) baseUrl = new URL(walletAddress.url).origin diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 3840212c2e..75e59ec365 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -96,10 +96,12 @@ describe('QuoteService', (): void => { scale: debitAmount.assetScale }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: sendAssetId }) const { id: destinationAssetId } = await createAsset(deps, destinationAsset) receivingWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -434,7 +436,9 @@ describe('QuoteService', (): void => { }) test('fails on inactive wallet address', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const walletAddressUpdated = await WalletAddress.query( knex ).patchAndFetchById(walletAddress.id, { deactivatedAt: new Date() }) @@ -525,9 +529,11 @@ describe('QuoteService', (): void => { scale: 2 }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: asset.id }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: asset.id }) }) @@ -633,9 +639,11 @@ describe('QuoteService', (): void => { scale: 2 }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: sendAsset.id }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, assetId: receiveAsset.id }) }) diff --git a/packages/backend/src/open_payments/receiver/model.test.ts b/packages/backend/src/open_payments/receiver/model.test.ts index 0255c615f0..5976465ad5 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -38,7 +38,9 @@ describe('Receiver Model', (): void => { describe('constructor', () => { test('creates receiver', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id }) @@ -82,7 +84,9 @@ describe('Receiver Model', (): void => { }) test('throws if incoming payment is completed', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id }) @@ -105,7 +109,9 @@ describe('Receiver Model', (): void => { }) test('throws if incoming payment is expired', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id }) @@ -125,7 +131,9 @@ describe('Receiver Model', (): void => { }) test('throws if stream credentials has invalid ILP address', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id }) diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index ed3d05ebd7..debc5c8a56 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -81,6 +81,7 @@ describe('Receiver Service', (): void => { describe('local incoming payment', () => { test('resolves local incoming payment', async () => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, mockServerPort: Config.openPaymentsPort }) const incomingPayment = await createIncomingPayment(deps, { @@ -289,6 +290,7 @@ describe('Receiver Service', (): void => { }) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, mockServerPort: Config.openPaymentsPort, assetId: asset.id }) diff --git a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts index c97153ed42..0ea2017292 100644 --- a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts @@ -46,7 +46,9 @@ describe('Wallet Address Keys Routes', (): void => { describe('get', (): void => { test('returns 200 with all keys for a wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const keyOption = { walletAddressId: walletAddress.id, @@ -69,7 +71,9 @@ describe('Wallet Address Keys Routes', (): void => { }) test('returns 200 with empty array if no keys for a wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const ctx = createContext({ headers: { Accept: 'application/json' }, @@ -121,7 +125,9 @@ describe('Wallet Address Keys Routes', (): void => { }) test('throws 404 error for inactive wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await walletAddress.$query().patch({ deactivatedAt: new Date() }) diff --git a/packages/backend/src/open_payments/wallet_address/key/service.test.ts b/packages/backend/src/open_payments/wallet_address/key/service.test.ts index 8fa5802486..57363cb649 100644 --- a/packages/backend/src/open_payments/wallet_address/key/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/service.test.ts @@ -31,7 +31,9 @@ describe('Wallet Address Key Service', (): void => { }) beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { diff --git a/packages/backend/src/open_payments/wallet_address/middleware.test.ts b/packages/backend/src/open_payments/wallet_address/middleware.test.ts index 7aeb1f3ea5..72d0b0dfc5 100644 --- a/packages/backend/src/open_payments/wallet_address/middleware.test.ts +++ b/packages/backend/src/open_payments/wallet_address/middleware.test.ts @@ -355,7 +355,9 @@ describe('Wallet Address Middleware', (): void => { }) test('throws error for deactivated wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) ctx.walletAddressUrl = walletAddress.url await walletAddress.$query().patch({ deactivatedAt: new Date() }) @@ -372,7 +374,9 @@ describe('Wallet Address Middleware', (): void => { }) test('sets walletAddress on context and calls next', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) ctx.walletAddressUrl = walletAddress.url await expect( diff --git a/packages/backend/src/open_payments/wallet_address/model.test.ts b/packages/backend/src/open_payments/wallet_address/model.test.ts index 6aaeb79184..3dd586873d 100644 --- a/packages/backend/src/open_payments/wallet_address/model.test.ts +++ b/packages/backend/src/open_payments/wallet_address/model.test.ts @@ -413,7 +413,9 @@ describe('Models', (): void => { test.each(deactivatedAtCases)( '$description', async ({ value, expectedIsActive }) => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) if (value) { await walletAddress .$query(appContainer.knex) diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index 81dd603a1d..c6de194da7 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -8,6 +8,7 @@ import { WebhookEvent } from '../../webhook/model' import { WalletAddressKey } from '../../open_payments/wallet_address/key/model' import { AmountJSON } from '../amount' import { WalletAddressAdditionalProperty } from './additional_property/model' +import { Tenant } from '../../tenants/model' export class WalletAddress extends BaseModel @@ -18,6 +19,14 @@ export class WalletAddress } static relationMappings = () => ({ + tenant: { + relation: Model.HasOneRelation, + modelClass: Tenant, + join: { + from: 'walletAddresses.tenantId', + to: 'tenants.id' + } + }, asset: { relation: Model.HasOneRelation, modelClass: Asset, @@ -53,6 +62,8 @@ export class WalletAddress public readonly assetId!: string public asset!: Asset + public readonly tenantId!: string + // The cumulative received amount tracked by // `wallet_address.web_monetization` webhook events. // The value should be equivalent to the following query: diff --git a/packages/backend/src/open_payments/wallet_address/routes.test.ts b/packages/backend/src/open_payments/wallet_address/routes.test.ts index ee4777f43b..c75e5041b9 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.test.ts @@ -62,6 +62,7 @@ describe('Wallet Address Routes', (): void => { test('throws 404 error for inactive wallet address', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: faker.person.firstName() }) @@ -102,6 +103,7 @@ describe('Wallet Address Routes', (): void => { addPropNotVisibleInOpenPayments.fieldValue = 'it-is-not' addPropNotVisibleInOpenPayments.visibleInOpenPayments = false const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, publicName: faker.person.firstName(), additionalProperties: [addProp, addPropNotVisibleInOpenPayments] }) @@ -118,8 +120,9 @@ describe('Wallet Address Routes', (): void => { publicName: walletAddress.publicName, assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - authServer: config.authServerGrantUrl, - resourceServer: config.openPaymentsUrl, + // Ensure the tenant id is returned for auth and resource server: + authServer: `${config.authServerGrantUrl}/${config.operatorTenantId}`, + resourceServer: `${config.openPaymentsUrl}/${config.operatorTenantId}`, additionalProperties: { [addProp.fieldKey]: addProp.fieldValue } @@ -145,6 +148,7 @@ describe('Wallet Address Routes', (): void => { test('returns wallet address', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, publicName: faker.person.firstName() }) @@ -160,8 +164,9 @@ describe('Wallet Address Routes', (): void => { publicName: walletAddress.publicName, assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - authServer: config.authServerGrantUrl, - resourceServer: config.openPaymentsUrl + // Ensure the tenant id is returned for auth and resource server: + authServer: `${config.authServerGrantUrl}/${config.operatorTenantId}`, + resourceServer: `${config.openPaymentsUrl}/${config.operatorTenantId}` }) }) }) diff --git a/packages/backend/src/open_payments/wallet_address/routes.ts b/packages/backend/src/open_payments/wallet_address/routes.ts index 652394985e..339ef3c789 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.ts @@ -11,6 +11,7 @@ import { } from '../../shared/pagination' import { OpenPaymentsServerRouteError } from '../route-errors' import { IAppConfig } from '../../config/app' +import { ensureTrailingSlash } from '../../shared/utils' interface ServiceDependencies { config: IAppConfig @@ -60,8 +61,8 @@ export async function getWalletAddress( ) ctx.body = walletAddress.toOpenPaymentsType({ - authServer: deps.config.authServerGrantUrl, - resourceServer: deps.config.openPaymentsUrl + authServer: `${ensureTrailingSlash(deps.config.authServerGrantUrl)}${walletAddress.tenantId}`, + resourceServer: `${ensureTrailingSlash(deps.config.openPaymentsUrl)}${walletAddress.tenantId}` }) } diff --git a/packages/backend/src/open_payments/wallet_address/service.test.ts b/packages/backend/src/open_payments/wallet_address/service.test.ts index b2b7245010..cceeaba6bb 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -12,6 +12,7 @@ import { CreateOptions, FORBIDDEN_PATHS, WalletAddressService } from './service' import { AccountingService } from '../../accounting/service' import { createTestApp, TestContainer } from '../../tests/app' import { createAsset } from '../../tests/asset' +import { createTenant } from '../../tests/tenant' import { createWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' import { Config, IAppConfig } from '../../config/app' @@ -60,10 +61,12 @@ describe('Open Payments Wallet Address Service', (): void => { let options: CreateOptions beforeEach(async (): Promise => { - const { id: assetId } = await createAsset(deps) + const { id: tenantId } = await createTenant(deps) + const { id: assetId } = await createAsset(deps, undefined, tenantId) options = { url: 'https://alice.me/.well-known/pay', - assetId + assetId, + tenantId } }) @@ -176,7 +179,9 @@ describe('Open Payments Wallet Address Service', (): void => { `( 'Wallet address with initial isActive of $initialIsActive can be updated with $status status ', async ({ initialIsActive, status, expectedIsActive }): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) if (!initialIsActive) { await walletAddress.$query(knex).patch({ deactivatedAt: new Date() }) @@ -198,6 +203,7 @@ describe('Open Payments Wallet Address Service', (): void => { test('publicName', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: 'Initial Name' }) const newName = 'New Name' @@ -223,7 +229,9 @@ describe('Open Payments Wallet Address Service', (): void => { incomingPaymentExpiryMaxMs: 2592000000 * 3 }, async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const now = new Date('2023-06-01T00:00:00Z').getTime() jest.useFakeTimers({ now }) @@ -266,7 +274,9 @@ describe('Open Payments Wallet Address Service', (): void => { () => config, { walletAddressDeactivationPaymentGracePeriodMs: 2592000000 }, async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const now = new Date('2023-06-01T00:00:00Z').getTime() jest.useFakeTimers({ now }) @@ -302,6 +312,7 @@ describe('Open Payments Wallet Address Service', (): void => { describe('additionalProperties', (): void => { test('should do nothing if additionalProperties is undefined', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: 'Initial Name', additionalProperties: [ { @@ -331,6 +342,7 @@ describe('Open Payments Wallet Address Service', (): void => { test('should update to [] (deleting all) when additionalProperties is []', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'key1', @@ -363,6 +375,7 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('should replace existing additionalProperties', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'key1', @@ -424,7 +437,9 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Get Wallet Address By Url', (): void => { test('can retrieve wallet address by url', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await expect( walletAddressService.getByUrl(walletAddress.url) ).resolves.toEqual(walletAddress) @@ -455,7 +470,9 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Get Or Poll Wallet Addres By Url', (): void => { describe('existing wallet address', (): void => { test('can retrieve wallet address by url', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await expect( walletAddressService.getOrPollByUrl(walletAddress.url) ).resolves.toEqual(walletAddress) @@ -501,6 +518,7 @@ describe('Open Payments Wallet Address Service', (): void => { (async () => { await sleep(5) return createWalletAddress(deps, { + tenantId: Config.operatorTenantId, url: walletAddressUrl }) })() @@ -530,7 +548,8 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Wallet Address pagination', (): void => { describe('getPage', (): void => { getPageTests({ - createModel: () => createWalletAddress(deps), + createModel: () => + createWalletAddress(deps, { tenantId: Config.operatorTenantId }), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => walletAddressService.getPage(pagination, sortOrder) }) @@ -541,7 +560,9 @@ describe('Open Payments Wallet Address Service', (): void => { let walletAddress: WalletAddress beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) describe.each` @@ -661,6 +682,7 @@ describe('Open Payments Wallet Address Service', (): void => { beforeEach(async (): Promise => { walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) }) @@ -734,6 +756,7 @@ describe('Open Payments Wallet Address Service', (): void => { for (let i = 0; i < 5; i++) { walletAddresses.push( await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId, createLiquidityAccount: true }) @@ -845,7 +868,9 @@ describe('Open Payments Wallet Address Service using Cache', (): void => { expectedCallCount }): Promise => { const spyCacheSet = jest.spyOn(walletAddressCache, 'set') - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) expect(spyCacheSet).toHaveBeenCalledTimes(1) if (!initialIsActive) { diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index b2b79bd5c3..c5f5d852d8 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -39,6 +39,7 @@ export type WalletAddressAdditionalPropertyInput = Pick< > export interface CreateOptions extends Options { + tenantId: string url: string assetId: string additionalProperties?: WalletAddressAdditionalPropertyInput[] @@ -64,12 +65,13 @@ export interface WalletAddressService { id: string, includeVisibleOnlyAddProps: boolean ): Promise - get(id: string): Promise - getByUrl(url: string): Promise + get(id: string, tenantId?: string): Promise + getByUrl(url: string, tenantId?: string): Promise getOrPollByUrl(url: string): Promise getPage( pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise processNext(): Promise triggerEvents(limit: number): Promise @@ -114,11 +116,11 @@ export async function createWalletAddressService({ walletAddressId, includeVisibleOnlyAddProps ), - get: (id) => getWalletAddress(deps, id), - getByUrl: (url) => getWalletAddressByUrl(deps, url), + get: (id, tenantId) => getWalletAddress(deps, id, tenantId), + getByUrl: (url, tenantId) => getWalletAddressByUrl(deps, url, tenantId), getOrPollByUrl: (url) => getOrPollByUrl(deps, url), - getPage: (pagination?, sortOrder?) => - getWalletAddressPage(deps, pagination, sortOrder), + getPage: (pagination?, sortOrder?, tenantId?) => + getWalletAddressPage(deps, pagination, sortOrder, tenantId), processNext: () => processNextWalletAddress(deps), triggerEvents: (limit) => triggerWalletAddressEvents(deps, limit) } @@ -168,6 +170,9 @@ async function createWalletAddress( } try { + const asset = await deps.assetService.get(options.assetId, options.tenantId) + if (!asset) return WalletAddressError.UnknownAsset + // Remove blank key/value pairs: const additionalProperties = options.additionalProperties ? cleanAdditionalProperties(options.additionalProperties) @@ -176,13 +181,13 @@ async function createWalletAddress( const walletAddress = await WalletAddress.query( deps.knex ).insertGraphAndFetch({ + tenantId: options.tenantId, url: options.url.toLowerCase(), publicName: options.publicName, - assetId: options.assetId, + assetId: asset.id, additionalProperties: additionalProperties }) - const asset = await deps.assetService.get(walletAddress.assetId) - if (asset) walletAddress.asset = asset + walletAddress.asset = asset await deps.walletAddressCache.set(walletAddress.id, walletAddress) return walletAddress @@ -260,12 +265,18 @@ async function updateWalletAddress( async function getWalletAddress( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { - const walletAdd = await deps.walletAddressCache.get(id) - if (walletAdd) return walletAdd + const inMem = await deps.walletAddressCache.get(id) + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } - const walletAddress = await WalletAddress.query(deps.knex).findById(id) + const query = WalletAddress.query(deps.knex) + if (tenantId) query.andWhere({ tenantId }) + + const walletAddress = await query.findById(id) if (walletAddress) { const asset = await deps.assetService.get(walletAddress.assetId) if (asset) walletAddress.asset = asset @@ -323,9 +334,13 @@ async function getOrPollByUrl( async function getWalletAddressByUrl( deps: ServiceDependencies, - url: string + url: string, + tenantId?: string ): Promise { - const walletAddress = await WalletAddress.query(deps.knex).findOne({ + const query = WalletAddress.query(deps.knex) + if (tenantId) query.andWhere({ tenantId }) + + const walletAddress = await query.findOne({ url: url.toLowerCase() }) if (walletAddress) { @@ -338,11 +353,18 @@ async function getWalletAddressByUrl( async function getWalletAddressPage( deps: ServiceDependencies, pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise { - return await WalletAddress.query(deps.knex) - .getPage(pagination, sortOrder) - .withGraphFetched('asset') + const query = WalletAddress.query(deps.knex) + if (tenantId) query.where({ tenantId }) + + const addresses = await query.getPage(pagination, sortOrder) + for (const address of addresses) { + const asset = await deps.assetService.get(address.assetId) + if (asset) address.asset = asset + } + return addresses } // Returns the id of the processed wallet address (if any). diff --git a/packages/backend/src/payment-method/handler/service.test.ts b/packages/backend/src/payment-method/handler/service.test.ts index 254963967c..6398f06b6f 100644 --- a/packages/backend/src/payment-method/handler/service.test.ts +++ b/packages/backend/src/payment-method/handler/service.test.ts @@ -47,6 +47,7 @@ describe('PaymentMethodHandlerService', (): void => { test('calls ilpPaymentService for ILP payment type', async (): Promise => { const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -75,6 +76,7 @@ describe('PaymentMethodHandlerService', (): void => { test('calls localPaymentService for local payment type', async (): Promise => { const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -105,6 +107,7 @@ describe('PaymentMethodHandlerService', (): void => { test('calls ilpPaymentService for ILP payment type', async (): Promise => { const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -139,6 +142,7 @@ describe('PaymentMethodHandlerService', (): void => { test('calls localPaymentService for local payment type', async (): Promise => { const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const { receiver, outgoingPayment } = diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index b2f37b7025..bba27b8315 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -67,10 +67,12 @@ describe('IlpPaymentService', (): void => { }) walletAddressMap['USD'] = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: assetMap['USD'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: assetMap['EUR'].id }) }) diff --git a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts index 9a4705f9c6..896c30dac6 100644 --- a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts @@ -37,6 +37,7 @@ describe('SPSP Middleware', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) ctx = setup({ diff --git a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts index 22c4f4842b..8b479ac893 100644 --- a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts +++ b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts @@ -27,7 +27,9 @@ describe('Stream Credentials Service', (): void => { }) beforeEach(async (): Promise => { - const { id: walletAddressId } = await createWalletAddress(deps) + const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) incomingPayment = await createIncomingPayment(deps, { walletAddressId }) diff --git a/packages/backend/src/payment-method/local/service.test.ts b/packages/backend/src/payment-method/local/service.test.ts index 72196b298d..c92cb4449d 100644 --- a/packages/backend/src/payment-method/local/service.test.ts +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -69,14 +69,17 @@ describe('LocalPaymentService', (): void => { }) walletAddressMap['USD'] = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: assetMap['USD'].id }) walletAddressMap['USD_9'] = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: assetMap['USD_9'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: assetMap['EUR'].id }) }) diff --git a/packages/backend/src/shared/baseModel.ts b/packages/backend/src/shared/baseModel.ts index 94e9c383b7..8a2c33a796 100644 --- a/packages/backend/src/shared/baseModel.ts +++ b/packages/backend/src/shared/baseModel.ts @@ -49,6 +49,7 @@ class PaginationQueryBuilder extends QueryBuilder< * Please read the spec before changing things: * https://relay.dev/graphql/connections.htm * @param pagination Pagination - cursors and limits. + * @param sortOrder SortOrder - Asc/Desc sort order. * @returns Model[] An array of Models that form a page. */ getPage( diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index bd60935d40..6220c37cfa 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -47,6 +47,7 @@ describe('Pagination', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) }) @@ -84,9 +85,11 @@ describe('Pagination', (): void => { const asset = await createAsset(deps) defaultWalletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) secondaryWalletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) debitAmount = { diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index 409f194c4e..fac329100b 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -9,7 +9,9 @@ import { poll, requestWithTimeout, sleep, - getTenantFromApiSignature + getTenantFromApiSignature, + ensureTrailingSlash, + urlWithoutTenantId } from './utils' import { AppServices, AppContext } from '../app' import { TestContainer, createTestApp } from '../tests/app' @@ -444,4 +446,20 @@ describe('utils', (): void => { expect(getSpy).toHaveBeenCalled() }) }) + + test('test ensuring trailing slash', async (): Promise => { + const path = '/utils' + + expect(ensureTrailingSlash(path)).toBe(`${path}/`) + expect(ensureTrailingSlash(`${path}/`)).toBe(`${path}/`) + }) + + test('test tenant id stripped from url', async (): Promise => { + expect( + urlWithoutTenantId( + 'http://happy-life-bank-test-auth:4106/cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' + ) + ).toBe('http://happy-life-bank-test-auth:4106') + expect(urlWithoutTenantId('http://happy-life')).toBe('http://happy-life') + }) }) diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index 7b328bae0b..8cd9b6d63e 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -98,7 +98,7 @@ export async function poll(args: PollArgs): Promise { } /** - * Omit distrubuted to all types in a union. + * Omit distributed to all types in a union. * @example * type WithoutA = UnionOmit<{ a: number; c: number } | { b: number }, 'a'> // { c: number } | { b: number } * const withoutAOK: WithoutA = { c: 1 } // OK @@ -114,14 +114,10 @@ function getSignatureParts(signature: string) { const signatureParts = signature.split(', ') const timestamp = signatureParts[0].split('=')[1] const signatureVersionAndDigest = signatureParts[1].split('=') - const signatureVersion = signatureVersionAndDigest[0].replace('v', '') - const signatureDigest = signatureVersionAndDigest[1] + const version = signatureVersionAndDigest[0].replace('v', '') + const digest = signatureVersionAndDigest[1] - return { - timestamp, - version: signatureVersion, - digest: signatureDigest - } + return { timestamp, version, digest } } function verifyApiSignatureDigest( @@ -240,3 +236,16 @@ export async function verifyApiSignature( config.adminApiSecret as string ) } + +export function ensureTrailingSlash(str: string): string { + if (!str.endsWith('/')) return `${str}/` + return str +} + +/** + * @param url remove the tenant id from the {url} + */ +export function urlWithoutTenantId(url: string): string { + if (url.length > 36 && validateId(url.slice(-36))) return url.slice(0, -37) + return url +} diff --git a/packages/backend/src/tests/asset.ts b/packages/backend/src/tests/asset.ts index d77fd1655b..63dd3994bc 100644 --- a/packages/backend/src/tests/asset.ts +++ b/packages/backend/src/tests/asset.ts @@ -25,14 +25,15 @@ export function randomLedger(): number { export async function createAsset( deps: IocContract, - options?: AssetOptions + options?: AssetOptions, + tenantId?: string ): Promise { const config = await deps.use('config') const assetService = await deps.use('assetService') const createOptions = options || randomAsset() const assetOrError = await assetService.create({ ...createOptions, - tenantId: config.operatorTenantId + tenantId: tenantId ? tenantId : config.operatorTenantId }) if (isAssetError(assetOrError)) { throw assetOrError diff --git a/packages/backend/src/tests/combinedPayment.ts b/packages/backend/src/tests/combinedPayment.ts index 504aceeb97..bb258e72a3 100644 --- a/packages/backend/src/tests/combinedPayment.ts +++ b/packages/backend/src/tests/combinedPayment.ts @@ -40,10 +40,14 @@ export async function createCombinedPayment( const sendAsset = await createAsset(deps) const receiveAsset = await createAsset(deps) const sendWalletAddressId = ( - await createWalletAddress(deps, { assetId: sendAsset.id }) + await createWalletAddress(deps, { + assetId: sendAsset.id, + tenantId: sendAsset.tenantId + }) ).id const receiveWalletAddress = await createWalletAddress(deps, { - assetId: receiveAsset.id + assetId: receiveAsset.id, + tenantId: sendAsset.tenantId }) const type = Math.random() < 0.5 ? PaymentType.Incoming : PaymentType.Outgoing diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index 3149d73a56..cc3c55092d 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -6,6 +6,7 @@ import { URL } from 'url' import { testAccessToken } from './app' import { createAsset } from './asset' +import { createTenant } from './tenant' import { AppServices } from '../app' import { isWalletAddressError } from '../open_payments/wallet_address/errors' import { WalletAddress } from '../open_payments/wallet_address/model' @@ -29,9 +30,12 @@ export async function createWalletAddress( options: Partial = {} ): Promise { const walletAddressService = await deps.use('walletAddressService') + const tenantIdToUse = options.tenantId || (await createTenant(deps)).id const walletAddressOrError = (await walletAddressService.create({ ...options, - assetId: options.assetId || (await createAsset(deps)).id, + assetId: + options.assetId || (await createAsset(deps, undefined, tenantIdToUse)).id, + tenantId: tenantIdToUse, url: options.url || `https://${faker.internet.domainName()}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { diff --git a/packages/backend/src/webhook/service.test.ts b/packages/backend/src/webhook/service.test.ts index bb33fdec72..4d91d50e02 100644 --- a/packages/backend/src/webhook/service.test.ts +++ b/packages/backend/src/webhook/service.test.ts @@ -116,8 +116,12 @@ describe('Webhook Service', (): void => { let events: WebhookEvent[] = [] beforeEach(async (): Promise => { - walletAddressIn = await createWalletAddress(deps) - walletAddressOut = await createWalletAddress(deps) + walletAddressIn = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) + walletAddressOut = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) incomingPaymentIds = [ ( await createIncomingPayment(deps, { diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index 1116595860..117ed190d9 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -373,6 +373,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -1263,6 +1265,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1496,6 +1499,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -2417,6 +2422,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 46dad2dedb..4bc9d24de2 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -373,6 +373,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -1263,6 +1265,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1496,6 +1499,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -2417,6 +2422,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; diff --git a/packages/mock-account-service-lib/src/types.ts b/packages/mock-account-service-lib/src/types.ts index 76c90bfda8..f48dad9b63 100644 --- a/packages/mock-account-service-lib/src/types.ts +++ b/packages/mock-account-service-lib/src/types.ts @@ -49,6 +49,7 @@ export interface Config { authServerDomain: string graphqlUrl: string idpSecret: string + operatorTenantId: string } export interface Webhook { id: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4eb0b61cf..f0840fe900 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -794,6 +794,9 @@ importers: mock-account-service-lib: specifier: workspace:* version: link:../../packages/mock-account-service-lib + uuid: + specifier: ^9.0.1 + version: 9.0.1 yaml: specifier: ^2.7.0 version: 2.7.0 diff --git a/test/integration/lib/generated/graphql.ts b/test/integration/lib/generated/graphql.ts index 46dad2dedb..4bc9d24de2 100644 --- a/test/integration/lib/generated/graphql.ts +++ b/test/integration/lib/generated/graphql.ts @@ -373,6 +373,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -1263,6 +1265,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1496,6 +1499,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -2417,6 +2422,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; diff --git a/test/integration/lib/test-actions/index.ts b/test/integration/lib/test-actions/index.ts index c47998ef2e..253bd04697 100644 --- a/test/integration/lib/test-actions/index.ts +++ b/test/integration/lib/test-actions/index.ts @@ -1,6 +1,6 @@ import assert from 'assert' import { MockASE } from '../mock-ase' -import { parseCookies } from '../utils' +import { parseCookies, urlWithoutTenantId } from '../utils' import { WalletAddress, PendingGrant } from '@interledger/open-payments' import { AdminActions, createAdminActions } from './admin' import { OpenPaymentsActions, createOpenPaymentsActions } from './open-payments' @@ -54,9 +54,9 @@ async function consentInteraction( idpSecret ) - // Finish interacton + // Finish interaction const finishResponse = await fetch( - `${senderWalletAddress.authServer}/interact/${interactId}/${nonce}/finish`, + `${urlWithoutTenantId(senderWalletAddress.authServer)}/interact/${interactId}/${nonce}/finish`, { method: 'GET', headers: { @@ -81,9 +81,9 @@ async function consentInteractionWithInteractRef( idpSecret ) - // Finish interacton + // Finish interaction const finishResponse = await fetch( - `${senderWalletAddress.authServer}/interact/${interactId}/${nonce}/finish`, + `${urlWithoutTenantId(senderWalletAddress.authServer)}/interact/${interactId}/${nonce}/finish`, { method: 'GET', headers: { diff --git a/test/integration/lib/test-actions/open-payments.ts b/test/integration/lib/test-actions/open-payments.ts index 6f54f25b68..0f3dc23c61 100644 --- a/test/integration/lib/test-actions/open-payments.ts +++ b/test/integration/lib/test-actions/open-payments.ts @@ -12,7 +12,13 @@ import { isPendingGrant } from '@interledger/open-payments' import { MockASE } from '../mock-ase' -import { UnionOmit, poll, pollCondition, wait } from '../utils' +import { + UnionOmit, + poll, + pollCondition, + wait, + urlWithoutTenantId +} from '../utils' import { WebhookEventType } from 'mock-account-service-lib' import { CreateOutgoingPaymentArgs, @@ -98,7 +104,7 @@ async function grantRequestIncomingPayment( const grant = await sendingASE.opClient.grant.request( { - url: receiverWalletAddress.authServer + url: urlWithoutTenantId(receiverWalletAddress.authServer) }, { access_token: { @@ -152,7 +158,7 @@ async function createIncomingPayment( const incomingPayment = await sendingASE.opClient.incomingPayment.create( { - url: receiverWalletAddress.resourceServer, + url: urlWithoutTenantId(receiverWalletAddress.resourceServer), accessToken }, createInput @@ -185,7 +191,7 @@ async function grantRequestQuote( const { sendingASE } = deps const grant = await sendingASE.opClient.grant.request( { - url: senderWalletAddress.authServer + url: urlWithoutTenantId(senderWalletAddress.authServer) }, { access_token: { @@ -211,7 +217,7 @@ async function createQuote( const { sendingASE } = deps return await sendingASE.opClient.quote.create( { - url: senderWalletAddress.resourceServer, + url: urlWithoutTenantId(senderWalletAddress.resourceServer), accessToken }, { @@ -235,7 +241,7 @@ async function grantRequestOutgoingPayment( const { receivingASE } = deps const grant = await receivingASE.opClient.grant.request( { - url: senderWalletAddress.authServer + url: urlWithoutTenantId(senderWalletAddress.authServer) }, { access_token: { @@ -319,7 +325,7 @@ async function createOutgoingPayment( const outgoingPayment = await sendingASE.opClient.outgoingPayment.create( { - url: senderWalletAddress.resourceServer, + url: urlWithoutTenantId(senderWalletAddress.resourceServer), accessToken: grantContinue.access_token.value }, { diff --git a/test/integration/lib/utils.ts b/test/integration/lib/utils.ts index 7c1f47cbbc..4a3cb56482 100644 --- a/test/integration/lib/utils.ts +++ b/test/integration/lib/utils.ts @@ -1,3 +1,5 @@ +import { validate, version } from 'uuid' + export function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -62,7 +64,7 @@ export function parseCookies(response: Response) { } /** - * Omit distrubuted to all types in a union. + * Omit distributed to all types in a union. * @example * type WithoutA = UnionOmit<{ a: number; c: number } | { b: number }, 'a'> // { c: number } | { b: number } * const withoutAOK: WithoutA = { c: 1 } // OK @@ -73,3 +75,15 @@ export function parseCookies(response: Response) { export type UnionOmit = T extends any ? Omit : never + +/** + * @param url remove the tenant id from the {url} + */ +export function urlWithoutTenantId(url: string): string { + if (url.length > 36 && validateId(url.slice(-36))) return url.slice(0, -37) + return url +} + +function validateId(id: string): boolean { + return validate(id) && version(id) === 4 +} diff --git a/test/integration/package.json b/test/integration/package.json index 6f8d221915..efbbd22699 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -27,6 +27,7 @@ "json-canonicalize": "^1.0.6", "koa": "^2.15.3", "mock-account-service-lib": "workspace:*", - "yaml": "^2.7.0" + "yaml": "^2.7.0", + "uuid": "^9.0.1" } }