diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru index 5b9b0277a9..5df49cfc71 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get receiver wallet address.bru @@ -37,7 +37,7 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru index 384cfa6282..4ed35375d7 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Get sender wallet address.bru @@ -28,7 +28,7 @@ script:post-response { } const body = res.getBody() - + bru.setEnvVar("senderAssetCode", body?.assetCode) bru.setEnvVar("senderAssetScale", body?.assetScale) @@ -38,7 +38,7 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("senderOpenPaymentsAuthHost", body?.authServer); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru index 6335a518af..f238b40b1a 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Incoming Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru index 5c8213f66a..12bba40c6d 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments Without Quote/Grant Request Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru index afc6464ee6..093a481bae 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Create Incoming Payment.bru @@ -41,7 +41,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru index 9e5acc50c4..5df49cfc71 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Get receiver wallet address.bru @@ -22,11 +22,11 @@ script:pre-request { script:post-response { const url = require('url') - + if (res.getStatus() !== 200) { return } - + const body = res.getBody() bru.setEnvVar("receiverAssetCode", body?.assetCode) bru.setEnvVar("receiverAssetScale", body?.assetScale) @@ -37,7 +37,7 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru index 9665a40e32..7f52d5e87c 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Get sender wallet address.bru @@ -37,7 +37,7 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("senderOpenPaymentsAuthHost", body?.authServer); } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru index 6335a518af..f238b40b1a 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Incoming Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru index 5be7a46476..223d424d27 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru index 3c0736670d..a478247fea 100644 --- a/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru +++ b/bruno/collections/Rafiki/Examples/Open Payments/Grant Request Quote.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru index 071e7df0be..d6d4ab3a27 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Create Incoming Payment.bru @@ -36,7 +36,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); bru.setEnvVar("quoteDebitAmount", JSON.stringify({ diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru index d59f39b24f..41033ba257 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Get receiver wallet address.bru @@ -28,7 +28,7 @@ script:post-response { } const body = res.getBody() - + bru.setEnvVar("receiverAssetCode", body?.assetCode) bru.setEnvVar("receiverAssetScale", body?.assetScale) @@ -38,7 +38,7 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("receiverOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("receiverOpenPaymentsAuthHost", body?.authServer); } diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru index 9665a40e32..7f52d5e87c 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Get sender wallet address.bru @@ -37,7 +37,7 @@ script:post-response { authUrl.hostname.includes('happy-life-bank') ){ const port = authUrl.hostname.includes('cloud-nine-wallet')? authUrl.port: Number(authUrl.port) + 1000 - bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port ); + bru.setEnvVar("senderOpenPaymentsAuthHost", authUrl.protocol + '//localhost:' + port + authUrl.path); } else { bru.setEnvVar("senderOpenPaymentsAuthHost", body?.authServer); } diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru index 6335a518af..f238b40b1a 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Incoming Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru index 5be7a46476..223d424d27 100644 --- a/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru +++ b/bruno/collections/Rafiki/Examples/Web Monetization/Grant Request Outgoing Payment.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{senderOpenPaymentsAuthHost}}/ + url: {{senderOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru index c718828e1c..8ed22f916a 100644 --- a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/Create Incoming Payment.bru @@ -42,7 +42,7 @@ script:pre-request { script:post-response { const body = res.getBody(); - + if (body?.id) { bru.setEnvVar("incomingPaymentId", body.id.split("/").pop()); } diff --git a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru index 9fd7a87872..18a5fc0038 100644 --- a/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru +++ b/bruno/collections/Rafiki/Open Payments APIs/Incoming Payments/List Incoming Payments.bru @@ -10,7 +10,7 @@ get { auth: none } -query { +params:query { first: 10 wallet-address: {{receiverWalletAddress}} ~cursor: ea3bf38f-2719-4473-a0f7-4ba967d1d81b diff --git a/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru b/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru index 0f5d41e8d7..4e487b9996 100644 --- a/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru +++ b/bruno/collections/Rafiki/Open Payments Auth APIs/Grants/Grant Request.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{receiverOpenPaymentsAuthHost}}/ + url: {{receiverOpenPaymentsAuthHost}} body: json auth: none } diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Incoming Payment.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Incoming Payment.bru index 209041819c..4861feaa69 100644 --- a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Incoming Payment.bru +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Incoming Payment.bru @@ -45,7 +45,8 @@ body:graphql:vars { }, "incomingAmount": null, "walletAddressId": "{{walletAddressId}}" - } + }, + "tenantId": "{{tenantId}}" } } diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Get Incoming Payment By Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Incoming Payment By Tenant.bru new file mode 100644 index 0000000000..48e6c925a6 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Incoming Payment By Tenant.bru @@ -0,0 +1,48 @@ +meta { + name: Get Incoming Payment By Tenant + type: graphql + seq: 54 +} + +post { + url: {{RafikiGraphqlHost}}/{{tenantId}}/graphql + body: graphql + auth: none +} + +body:graphql { + query GetIncomingPayment($id: String!) { + incomingPayment(id: $id) { + id + walletAddressId + client + state + expiresAt + incomingAmount { + value + assetCode + assetScale + } + receivedAmount { + value + assetCode + assetScale + } + metadata + createdAt + } + } +} + +body:graphql:vars { + { + "id": "{{incomingPaymentId}}", + "tenantId": "{{tenantId}}" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/environments/Local Playground.bru b/bruno/collections/Rafiki/environments/Local Playground.bru index 24924232e4..b637cb0bae 100644 --- a/bruno/collections/Rafiki/environments/Local Playground.bru +++ b/bruno/collections/Rafiki/environments/Local Playground.bru @@ -31,4 +31,5 @@ vars { assetCode: USD assetScale: 2 senderTenantId: 438fa74a-fa7d-4317-9ced-dde32ece1787 + RafikiGraphqlHostTenantId: 438fa74a-fa7d-4317-9ced-dde32ece1787 } diff --git a/localenv/README.md b/localenv/README.md index 9677d391b5..eab4f5df36 100644 --- a/localenv/README.md +++ b/localenv/README.md @@ -189,6 +189,14 @@ Authentication is disabled by default for ease of development, but it can be ena pnpm localenv:compose:adminauth up ``` +The Admin UI requires a valid API secret and tenant id to make requests to the Admin APIs, which must be submitted via a form on the frontend. For our convenience, we log a link on Mock Account Servicing Entity (MASE) start that can be used to access the Admin UI and set the credentials automatically. The credentials used pull from the MASE’s `SIGNATURE_SECRET` and `OPERATOR_TENANT_ID` environment variables. + +``` +cloud-nine-mock-ase-1 | Local Dev Setup: +cloud-nine-mock-ase-1 | Use this URL to access the frontend with operator tenant credentials: +cloud-nine-mock-ase-1 | http://localhost:3010/?tenantId=438fa74a-fa7d-4317-9ced-dde32ece1787&apiSecret=iyIgCprjb9uL8wFckR%2BpLEkJWMB7FJhgkvqhTQR%2F964%3D +``` + For additional details on using the Rafiki Admin application within the Local Playground, including enabling authentication and managing users, see the [Local Playground Rafiki Admin](https://rafiki.dev/integration/playground/overview/#rafiki-admin) documentation. # Reference diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 99ff7f0619..21d00b3971 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -611,6 +614,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1025,6 +1030,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1268,6 +1275,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1278,6 +1286,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1370,6 +1379,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -2236,6 +2247,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2359,6 +2371,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2460,6 +2473,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2569,6 +2583,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/auth/migrations/20241206232423_add_tenant_to_grant.js b/packages/auth/migrations/20241206232423_add_tenant_to_grant.js new file mode 100644 index 0000000000..279e0eeff5 --- /dev/null +++ b/packages/auth/migrations/20241206232423_add_tenant_to_grant.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('grants', function (table) { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "grants" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('grants', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('grants', function (table) { + table.dropColumn('tenantId') + }) +} diff --git a/packages/auth/src/access/service.test.ts b/packages/auth/src/access/service.test.ts index ff7a32229b..3aba7b787d 100644 --- a/packages/auth/src/access/service.test.ts +++ b/packages/auth/src/access/service.test.ts @@ -1,7 +1,5 @@ -import { faker } from '@faker-js/faker' import nock from 'nock' import { Knex } from 'knex' -import { v4 } from 'uuid' import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { Config } from '../config/app' @@ -9,11 +7,13 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { AccessService } from './service' -import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model' +import { Grant } from '../grant/model' import { IncomingPaymentRequest, OutgoingPaymentRequest } from './types' -import { generateNonce, generateToken } from '../shared/utils' +import { generateBaseGrant } from '../tests/grant' import { AccessType, AccessAction } from '@interledger/open-payments' import { Access } from './model' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access Service', (): void => { let deps: IocContract @@ -22,19 +22,11 @@ describe('Access Service', (): void => { let trx: Knex.Transaction let grant: Grant - const generateBaseGrant = () => ({ - state: GrantState.Pending, - startMethod: [StartMethod.Redirect], - continueToken: generateToken(), - continueId: v4(), - finishMethod: FinishMethod.Redirect, - finishUri: 'https://example.com/finish', - clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) - }) - beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(generateBaseGrant()) + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ tenantId: tenant.id }) + ) }) beforeAll(async (): Promise => { diff --git a/packages/auth/src/access/utils.test.ts b/packages/auth/src/access/utils.test.ts index 351a535cf1..0da2531a18 100644 --- a/packages/auth/src/access/utils.test.ts +++ b/packages/auth/src/access/utils.test.ts @@ -17,6 +17,8 @@ import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { generateToken, generateNonce } from '../shared/utils' import { compareRequestAndGrantAccessItems } from './utils' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access utilities', (): void => { let deps: IocContract @@ -25,6 +27,7 @@ describe('Access utilities', (): void => { let identifier: string let grant: Grant let grantAccessItem: Access + let tenant: Tenant const receiver: string = 'https://wallet.com/alice/incoming-payments/12341234-1234-1234-1234-123412341234' @@ -36,6 +39,7 @@ describe('Access utilities', (): void => { beforeEach(async (): Promise => { identifier = `https://example.com/${v4()}` + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -44,7 +48,8 @@ describe('Access utilities', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) + client: faker.internet.url({ appendSlash: false }), + tenantId: tenant.id }) grantAccessItem = await Access.query(trx).insertAndFetch({ @@ -241,7 +246,8 @@ describe('Access utilities', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com/finish', clientNonce: generateNonce(), - client: faker.internet.url({ appendSlash: false }) + client: faker.internet.url({ appendSlash: false }), + tenantId: tenant.id }) const grantAccessItem = await Access.query(trx).insertAndFetch({ diff --git a/packages/auth/src/accessToken/routes.test.ts b/packages/auth/src/accessToken/routes.test.ts index ac9fb14964..fd4fb58066 100644 --- a/packages/auth/src/accessToken/routes.test.ts +++ b/packages/auth/src/accessToken/routes.test.ts @@ -24,6 +24,8 @@ import { import { GrantService } from '../grant/service' import { AccessTokenService } from './service' import { GNAPErrorCode } from '../shared/gnapErrors' +import { generateTenant } from '../tests/tenant' +import { Tenant } from '../tenant/model' describe('Access Token Routes', (): void => { let deps: IocContract @@ -96,7 +98,11 @@ describe('Access Token Routes', (): void => { const method = 'POST' beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS @@ -367,7 +373,11 @@ describe('Access Token Routes', (): void => { let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) token = await AccessToken.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_TOKEN @@ -406,7 +416,11 @@ describe('Access Token Routes', (): void => { let token: AccessToken beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(BASE_GRANT) + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch({ + ...BASE_GRANT, + tenantId: tenant.id + }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index ba7029ae18..b1438f0a8a 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -20,6 +20,8 @@ import { AccessItem } from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Access Token Service', (): void => { let deps: IocContract @@ -63,8 +65,9 @@ describe('Access Token Service', (): void => { let grant: Grant beforeEach(async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( - generateBaseGrant({ state: GrantState.Approved }) + generateBaseGrant({ state: GrantState.Approved, tenantId: tenant.id }) ) grant.access = [ await Access.query(trx).insertAndFetch({ @@ -186,8 +189,9 @@ describe('Access Token Service', (): void => { }) test('Introspection only returns requested access', async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) const grantWithTwoAccesses = await Grant.query(trx).insertAndFetch( - generateBaseGrant({ state: GrantState.Approved }) + generateBaseGrant({ state: GrantState.Approved, tenantId: tenant.id }) ) grantWithTwoAccesses.access = [ await Access.query(trx).insertAndFetch({ @@ -247,11 +251,14 @@ describe('Access Token Service', (): void => { }) describe('Revoke', (): void => { + let tenant: Tenant let grant: Grant let token: AccessToken beforeEach(async (): Promise => { + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) @@ -352,8 +359,10 @@ describe('Access Token Service', (): void => { let token: AccessToken let originalTokenValue: string beforeEach(async (): Promise => { + const tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) grant = await Grant.query(trx).insertAndFetch( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index 40895cc000..66bc50b3a1 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -268,7 +268,7 @@ export class App { /* Back-channel GNAP Routes */ // Grant Initiation router.post( - '/', + '/:tenantId', createValidatorMiddleware(openApi.authServerSpec, { path: '/', method: HttpMethod.POST @@ -279,7 +279,7 @@ export class App { // Grant Continue router.post( - '/continue/:id', + '/:tenantId/continue/:id', createValidatorMiddleware(openApi.authServerSpec, { path: '/continue/{id}', method: HttpMethod.POST @@ -290,7 +290,7 @@ export class App { // Grant Cancel router.delete( - '/continue/:id', + '/:tenantId/continue/:id', createValidatorMiddleware(openApi.authServerSpec, { path: '/continue/{id}', method: HttpMethod.DELETE diff --git a/packages/auth/src/grant/model.ts b/packages/auth/src/grant/model.ts index 54cfb39201..c395cece82 100644 --- a/packages/auth/src/grant/model.ts +++ b/packages/auth/src/grant/model.ts @@ -9,6 +9,7 @@ import { } from '@interledger/open-payments' import { AccessToken, toOpenPaymentsAccessToken } from '../accessToken/model' import { Interaction } from '../interaction/model' +import { Tenant } from '../tenant/model' export enum StartMethod { Redirect = 'redirect' @@ -61,6 +62,14 @@ export class Grant extends BaseModel { from: 'grants.id', to: 'interactions.grantId' } + }, + tenant: { + relation: Model.HasOneRelation, + modelClass: join(__dirname, '../tenant/model'), + join: { + from: 'grants.tenantId', + to: 'tenants.id' + } } }) public access!: Access[] @@ -79,6 +88,10 @@ export class Grant extends BaseModel { public lastContinuedAt!: Date + public tenantId!: string + + public tenant?: Tenant + public $beforeInsert(context: QueryContext): void { super.$beforeInsert(context) this.lastContinuedAt = new Date() @@ -125,7 +138,7 @@ export function toOpenPaymentPendingGrant( access_token: { value: grant.continueToken }, - uri: `${authServerUrl}/continue/${grant.continueId}`, + uri: `${authServerUrl}/${grant.tenantId}/continue/${grant.continueId}`, wait: waitTimeSeconds } } @@ -145,7 +158,7 @@ export function toOpenPaymentsGrantContinuation( access_token: { value: grant.continueToken }, - uri: `${args.authServerUrl}/continue/${grant.continueId}`, + uri: `${args.authServerUrl}/${grant.tenantId}/continue/${grant.continueId}`, wait: args.waitTimeSeconds } } @@ -165,7 +178,7 @@ export function toOpenPaymentsGrant( access_token: { value: grant.continueToken }, - uri: `${args.authServerUrl}/continue/${grant.continueId}` + uri: `${args.authServerUrl}/${grant.tenantId}/continue/${grant.continueId}` } } } @@ -192,3 +205,11 @@ export function isRevokedGrant(grant: Grant): boolean { grant.finalizationReason === GrantFinalization.Revoked ) } + +export interface GrantWithTenant extends Grant { + tenant: NonNullable +} + +export function isGrantWithTenant(grant: Grant): grant is GrantWithTenant { + return !!grant.tenant +} diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index 5175fb02c0..961c117967 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -32,10 +32,17 @@ import { AccessTokenService } from '../accessToken/service' import { generateNonce } from '../shared/utils' import { ClientService } from '../client/service' import { withConfigOverride } from '../tests/helpers' -import { AccessAction, AccessType } from '@interledger/open-payments' +import { + AccessAction, + AccessType, + GrantContinuation, + PendingGrant +} from '@interledger/open-payments' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' import { GNAPErrorCode } from '../shared/gnapErrors' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' export const TEST_CLIENT_DISPLAY = { name: 'Test Client', @@ -71,6 +78,18 @@ const BASE_GRANT_REQUEST = { } } +function getGrantContinueId(continueUrl: string): string { + const continueUrlObj = new URL(continueUrl) + const pathItems = continueUrlObj.pathname.split('/') + return pathItems[pathItems.length - 1] +} + +function getInteractionId(redirectUrl: string): string { + const redirectUrlObj = new URL(redirectUrl) + const pathItems = redirectUrlObj.pathname.split('/') + return pathItems[pathItems.length - 2] +} + describe('Grant Routes', (): void => { let deps: IocContract let appContainer: TestContainer @@ -80,10 +99,14 @@ describe('Grant Routes', (): void => { let clientService: ClientService let interactionService: InteractionService + let tenant: Tenant let grant: Grant beforeEach(async (): Promise => { - grant = await Grant.query().insert(generateBaseGrant()) + tenant = await Tenant.query().insertAndFetch(generateTenant()) + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -173,7 +196,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const body = { access_token: { @@ -207,6 +232,14 @@ describe('Grant Routes', (): void => { ).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as GrantContinuation).continue.uri + ), + continueToken: (ctx.body as GrantContinuation) + .continue.access_token.value + }) + assert.ok(createdGrant) expect(ctx.body).toEqual({ access_token: { value: expect.any(String), @@ -216,9 +249,9 @@ describe('Grant Routes', (): void => { }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String) + uri: `${config.authServerUrl}/${tenant.id}/continue/${createdGrant.continueId}` } }) } @@ -252,7 +285,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -260,16 +295,37 @@ describe('Grant Routes', (): void => { await expect(grantRoutes.create(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as PendingGrant).continue.uri + ), + continueToken: (ctx.body as PendingGrant).continue.access_token.value + }) + assert.ok(createdGrant) + const createdInteraction = await Interaction.query().findOne({ + nonce: (ctx.body as PendingGrant).interact.finish, + id: getInteractionId((ctx.body as PendingGrant).interact.redirect) + }) + assert.ok(createdInteraction) + const expectedRedirectUrl = new URL( + config.authServerUrl + + `/interact/${createdInteraction.id}/${createdInteraction.nonce}` + ) + expectedRedirectUrl.searchParams.set( + 'clientName', + TEST_CLIENT_DISPLAY.name + ) + expectedRedirectUrl.searchParams.set('clientUri', CLIENT) expect(ctx.body).toEqual({ interact: { - redirect: expect.any(String), - finish: expect.any(String) + redirect: expectedRedirectUrl.toString(), + finish: createdInteraction.nonce }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String), + uri: `${config.authServerUrl}/${tenant.id}/continue/${createdGrant.continueId}`, wait: Config.waitTimeSeconds } }) @@ -277,6 +333,35 @@ describe('Grant Routes', (): void => { scope.done() }) + test('Does not create interactive grant if tenant has no idp', async (): Promise => { + const unconfiguredTenant = await Tenant.query().insertAndFetch({ + idpConsentUrl: undefined, + idpSecret: undefined + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + { + tenantId: unconfiguredTenant.id + } + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 400, + code: GNAPErrorCode.InvalidRequest, + message: 'invalid tenant' + }) + }) + test('Does not create grant if token issuance fails', async (): Promise => { jest .spyOn(accessTokenService, 'create') @@ -291,7 +376,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const body = { access_token: { @@ -327,7 +414,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { ...BASE_GRANT_REQUEST, interact: undefined } @@ -364,7 +453,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -386,7 +477,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) const grantRequest = { @@ -423,7 +516,9 @@ describe('Grant Routes', (): void => { url, method }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = BASE_GRANT_REQUEST @@ -434,6 +529,52 @@ describe('Grant Routes', (): void => { message: "missing required request field 'client'" }) }) + + test('Fails to initiate a grant without providing a tenant id', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + {} + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidRequest, + message: 'Not Found' + }) + }) + + test('Fails to initiate a grant if the provided tenant does not exist', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + url, + method + }, + { + tenantId: v4() + } + ) + + ctx.request.body = BASE_GRANT_REQUEST + + await expect(grantRoutes.create(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidRequest, + message: 'Not Found' + }) + }) }) describe('/continue', (): void => { @@ -443,6 +584,7 @@ describe('Grant Routes', (): void => { beforeEach(async (): Promise => { grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Approved }) ) @@ -476,7 +618,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -495,6 +638,14 @@ describe('Grant Routes', (): void => { assert.ok(accessToken) expect(ctx.status).toBe(200) + const createdGrant = await Grant.query().findOne({ + continueId: getGrantContinueId( + (ctx.body as GrantContinuation).continue.uri + ), + continueToken: (ctx.body as GrantContinuation).continue.access_token + .value + }) + assert.ok(createdGrant) expect(ctx.body).toEqual({ access_token: { value: accessToken.value, @@ -510,9 +661,9 @@ describe('Grant Routes', (): void => { }, continue: { access_token: { - value: expect.any(String) + value: createdGrant.continueToken }, - uri: expect.any(String) + uri: `${config.authServerUrl}/${tenant.id}/continue/${createdGrant.continueId}` } }) }) @@ -527,7 +678,8 @@ describe('Grant Routes', (): void => { } }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) @@ -545,6 +697,7 @@ describe('Grant Routes', (): void => { test('Cannot issue access token if grant has not been granted', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Pending }) ) @@ -571,7 +724,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -589,6 +743,7 @@ describe('Grant Routes', (): void => { test('Cannot issue access token if grant has been revoked', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -611,7 +766,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -635,7 +791,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -659,7 +816,9 @@ describe('Grant Routes', (): void => { Authorization: `GNAP ${grant.continueToken}` } }, - {} + { + tenantId: tenant.id + } ) ctx.request.body = { @@ -683,7 +842,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -698,8 +858,61 @@ describe('Grant Routes', (): void => { }) }) + test('Cannot continue if tenant id is not provided', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `GNAP ${grant.continueToken}` + } + }, + { + id: grant.continueId + } + ) + + ctx.request.body = { + interact_ref: interaction.ref + } + + await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidContinuation, + message: 'grant not found' + }) + }) + + test('Cannot continue if tenant does not exist', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `GNAP ${grant.continueToken}` + } + }, + { + id: grant.continueId, + tenantId: v4() + } + ) + + ctx.request.body = { + interact_ref: interaction.ref + } + + await expect(grantRoutes.continue(ctx)).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.InvalidContinuation, + message: 'grant not found' + }) + }) + test('Honors wait value when continuing too early', async (): Promise => { - const grantWithWait = await Grant.query().insert(generateBaseGrant()) + const grantWithWait = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -721,7 +934,8 @@ describe('Grant Routes', (): void => { } }, { - id: grantWithWait.continueId + id: grantWithWait.continueId, + tenantId: tenant.id } ) @@ -746,6 +960,7 @@ describe('Grant Routes', (): void => { async ({ state }): Promise => { const polledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state, noFinishMethod: true }) @@ -779,7 +994,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: polledGrant.continueId + id: polledGrant.continueId, + tenantId: tenant.id } ) @@ -842,6 +1058,7 @@ describe('Grant Routes', (): void => { test('Cannot poll a finalized grant', async (): Promise => { const finalizedPolledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, noFinishMethod: true }) @@ -885,7 +1102,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) @@ -903,6 +1121,7 @@ describe('Grant Routes', (): void => { test('Cannot poll a grant faster than its wait method', async (): Promise => { const polledGrant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }) ) @@ -929,7 +1148,8 @@ describe('Grant Routes', (): void => { method: 'POST' }, { - id: polledGrant.continueId + id: polledGrant.continueId, + tenantId: tenant.id } ) @@ -952,7 +1172,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).resolves.toBeUndefined() @@ -963,6 +1184,7 @@ describe('Grant Routes', (): void => { test('Can revoke an existing grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Issued }) @@ -976,7 +1198,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).resolves.toBeUndefined() @@ -987,6 +1210,7 @@ describe('Grant Routes', (): void => { test('Cannot revoke an already revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -1000,7 +1224,8 @@ describe('Grant Routes', (): void => { } }, { - id: grant.continueId + id: grant.continueId, + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject({ @@ -1020,7 +1245,8 @@ describe('Grant Routes', (): void => { } }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject({ @@ -1048,7 +1274,8 @@ describe('Grant Routes', (): void => { : undefined }, { - id: v4() + id: v4(), + tenantId: tenant.id } ) await expect(grantRoutes.revoke(ctx)).rejects.toMatchObject(error) diff --git a/packages/auth/src/grant/routes.ts b/packages/auth/src/grant/routes.ts index 17e6f09128..9d4781d011 100644 --- a/packages/auth/src/grant/routes.ts +++ b/packages/auth/src/grant/routes.ts @@ -22,6 +22,8 @@ import { InteractionService } from '../interaction/service' import { canSkipInteraction } from './utils' import { GNAPErrorCode, GNAPServerRouteError } from '../shared/gnapErrors' import { generateRouteLogs } from '../shared/utils' +import { TenantService } from '../tenant/service' +import { Tenant, isTenantWithIdp } from '../tenant/model' interface ServiceDependencies extends BaseService { grantService: GrantService @@ -29,6 +31,7 @@ interface ServiceDependencies extends BaseService { accessTokenService: AccessTokenService accessService: AccessService interactionService: InteractionService + tenantService: TenantService config: IAppConfig } @@ -72,6 +75,7 @@ export function createGrantRoutes({ accessTokenService, accessService, interactionService, + tenantService, logger, config }: ServiceDependencies): GrantRoutes { @@ -94,6 +98,7 @@ export function createGrantRoutes({ accessTokenService, accessService, interactionService, + tenantService, logger: log, config } @@ -108,6 +113,16 @@ async function createGrant( deps: ServiceDependencies, ctx: CreateContext ): Promise { + const { tenantId } = ctx.params + const tenant = tenantId ? await deps.tenantService.get(tenantId) : undefined + + if (!tenant) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.InvalidRequest, + 'Not Found' + ) + } let noInteractionRequired: boolean try { noInteractionRequired = canSkipInteraction(deps.config, ctx.request.body) @@ -119,14 +134,15 @@ async function createGrant( ) } if (noInteractionRequired) { - await createApprovedGrant(deps, ctx) + await createApprovedGrant(deps, tenantId, ctx) } else { - await createPendingGrant(deps, ctx) + await createPendingGrant(deps, tenant, ctx) } } async function createApprovedGrant( deps: ServiceDependencies, + tenantId: string, ctx: CreateContext ): Promise { const { body } = ctx.request @@ -135,7 +151,7 @@ async function createApprovedGrant( let grant: Grant let accessToken: AccessToken try { - grant = await grantService.create(body, trx) + grant = await grantService.create(body, tenantId, trx) accessToken = await deps.accessTokenService.create(grant.id, trx) await trx.commit() } catch (err) { @@ -167,6 +183,7 @@ async function createApprovedGrant( async function createPendingGrant( deps: ServiceDependencies, + tenant: Tenant, ctx: CreateContext ): Promise { const { body } = ctx.request @@ -179,6 +196,14 @@ async function createPendingGrant( ) } + if (!isTenantWithIdp(tenant)) { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidRequest, + 'invalid tenant' + ) + } + const client = await deps.clientService.get(body.client) if (!client) { throw new GNAPServerRouteError( @@ -191,7 +216,7 @@ async function createPendingGrant( const trx = await Grant.startTransaction() try { - const grant = await grantService.create(body, trx) + const grant = await grantService.create(body, tenant.id, trx) const interaction = await interactionService.create(grant.id, trx) await trx.commit() @@ -355,7 +380,7 @@ async function continueGrant( params, headers } = ctx - const { id: continueId } = params + const { id: continueId, tenantId } = params const continueToken = (headers['authorization'] as string)?.split('GNAP ')[1] if (!continueId || !continueToken) { @@ -386,7 +411,8 @@ async function continueGrant( if ( !interaction || !isContinuableGrant(interaction.grant) || - !isMatchingContinueRequest(continueId, continueToken, interaction.grant) + !isMatchingContinueRequest(continueId, continueToken, interaction.grant) || + interaction.grant.tenantId !== tenantId ) { throw new GNAPServerRouteError( 404, @@ -435,7 +461,7 @@ async function revokeGrant( deps: ServiceDependencies, ctx: RevokeContext ): Promise { - const { id: continueId } = ctx.params + const { id: continueId, tenantId } = ctx.params const { grantService, logger } = deps const continueToken = (ctx.headers['authorization'] as string)?.split( 'GNAP ' @@ -456,7 +482,7 @@ async function revokeGrant( ) } - const revoked = await grantService.revokeGrant(grant.id) + const revoked = await grantService.revokeGrant(grant.id, tenantId) if (!revoked) { throw new GNAPServerRouteError( 404, diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index c1012ac944..7f4316ad09 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -24,20 +24,27 @@ import { AccessToken } from '../accessToken/model' import { Interaction, InteractionState } from '../interaction/model' import { Pagination, SortOrder } from '../shared/baseModel' import { getPageTests } from '../shared/baseModel.test' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Grant Service', (): void => { let deps: IocContract let appContainer: TestContainer let grantService: GrantService - let trx: Knex.Transaction + let knex: Knex + let tenant: Tenant beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) - + knex = appContainer.knex grantService = await deps.use('grantService') }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insertAndFetch(generateTenant()) + }) + afterEach(async (): Promise => { await truncateTables(appContainer.knex) }) @@ -48,13 +55,14 @@ describe('Grant Service', (): void => { describe('getPage', (): void => { getPageTests({ - createModel: () => createGrant(deps), + createModel: () => createGrant(deps, tenant.id), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => grantService.getPage(pagination, undefined, sortOrder) }) }) describe('grant flow', (): void => { + let tenant: Tenant let grant: Grant let access: Access let accessToken: AccessToken @@ -62,6 +70,7 @@ describe('Grant Service', (): void => { const CLIENT = faker.internet.url({ appendSlash: false }) beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) grant = await Grant.query().insert({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -70,7 +79,8 @@ describe('Grant Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT + client: CLIENT, + tenantId: tenant.id }) await Interaction.query().insert({ @@ -126,7 +136,7 @@ describe('Grant Service', (): void => { } } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) expect(grant).toMatchObject({ state: GrantState.Approved, @@ -140,7 +150,7 @@ describe('Grant Service', (): void => { }) await expect( - Access.query(trx) + Access.query(knex) .where({ grantId: grant.id }) @@ -170,7 +180,7 @@ describe('Grant Service', (): void => { interact } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) expect(grant).toMatchObject({ state: expectedState, @@ -179,7 +189,7 @@ describe('Grant Service', (): void => { }) await expect( - Access.query(trx) + Access.query(knex) .where({ grantId: grant.id }) @@ -266,13 +276,13 @@ describe('Grant Service', (): void => { interact: undefined } - const grant1 = await grantService.create(grantRequest) + const grant1 = await grantService.create(grantRequest, tenant.id) await grant1 .$query() .patch({ finalizationReason: GrantFinalization.Issued }) - const grant2 = await grantService.create(grantRequest) - const grant3 = await grantService.create(grantRequest) + const grant2 = await grantService.create(grantRequest, tenant.id) + const grant3 = await grantService.create(grantRequest, tenant.id) await grant3 .$query() .patch({ finalizationReason: GrantFinalization.Revoked }) @@ -346,9 +356,11 @@ describe('Grant Service', (): void => { describe('revoke', (): void => { test('Can revoke a grant', async (): Promise => { - await expect(grantService.revokeGrant(grant.id)).resolves.toEqual(true) + await expect( + grantService.revokeGrant(grant.id, tenant.id) + ).resolves.toEqual(true) - const revokedGrant = await Grant.query(trx).findById(grant.id) + const revokedGrant = await Grant.query(knex).findById(grant.id) expect(revokedGrant?.state).toEqual(GrantState.Finalized) expect(revokedGrant?.finalizationReason).toEqual( GrantFinalization.Revoked @@ -368,7 +380,9 @@ describe('Grant Service', (): void => { }) test('Can "revoke" unknown grant', async (): Promise => { - await expect(grantService.revokeGrant(v4())).resolves.toEqual(false) + await expect( + grantService.revokeGrant(v4(), tenant.id) + ).resolves.toEqual(false) }) }) @@ -386,15 +400,15 @@ describe('Grant Service', (): void => { } } - const grant = await grantService.create(grantRequest) + const grant = await grantService.create(grantRequest, tenant.id) const timeoutMs = 50 const lock = async (): Promise => { - return await Grant.transaction(async (trx) => { - await grantService.lock(grant.id, trx, timeoutMs) + return await Grant.transaction(async (knex) => { + await grantService.lock(grant.id, knex, timeoutMs) await new Promise((resolve) => setTimeout(resolve, timeoutMs + 10)) - await Grant.query(trx).findById(grant.id) + await Grant.query(knex).findById(grant.id) }) } await expect(Promise.all([lock(), lock()])).rejects.toThrowError( @@ -420,7 +434,7 @@ describe('Grant Service', (): void => { ] for (const { identifier, state, finalizationReason } of grantDetails) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) const updatedGrant = await grant .$query() .patchAndFetch({ state, finalizationReason }) diff --git a/packages/auth/src/grant/service.ts b/packages/auth/src/grant/service.ts index 051b3d0984..d3859ef7bc 100644 --- a/packages/auth/src/grant/service.ts +++ b/packages/auth/src/grant/service.ts @@ -8,7 +8,9 @@ import { GrantState, GrantFinalization, StartMethod, - FinishMethod + FinishMethod, + isGrantWithTenant, + GrantWithTenant } from './model' import { AccessRequest } from '../access/types' import { AccessService } from '../access/service' @@ -24,8 +26,12 @@ interface GrantFilter { export interface GrantService { getByIdWithAccess(grantId: string): Promise - create(grantRequest: GrantRequest, trx?: Transaction): Promise - markPending(grantId: string, trx?: Transaction): Promise + create( + grantRequest: GrantRequest, + tenantId: string, + trx?: Transaction + ): Promise + markPending(grantId: string, trx?: Transaction): Promise approve(grantId: string, trx?: Transaction): Promise finalize(grantId: string, reason: GrantFinalization): Promise getByContinue( @@ -33,7 +39,7 @@ export interface GrantService { continueToken: string, options?: GetByContinueOpts ): Promise - revokeGrant(grantId: string): Promise + revokeGrant(grantId: string, tenantId?: string): Promise getPage( pagination?: Pagination, filter?: GrantFilter, @@ -115,8 +121,8 @@ export async function createGrantService({ } return { getByIdWithAccess: (grantId: string) => getByIdWithAccess(grantId), - create: (grantRequest: GrantRequest, trx?: Transaction) => - create(deps, grantRequest, trx), + create: (grantRequest: GrantRequest, tenantId: string, trx?: Transaction) => + create(deps, grantRequest, tenantId, trx), markPending: (grantId: string, trx?: Transaction) => markPending(deps, grantId, trx), approve: (grantId: string) => approve(grantId), @@ -126,7 +132,8 @@ export async function createGrantService({ continueToken: string, opts: GetByContinueOpts ) => getByContinue(continueId, continueToken, opts), - revokeGrant: (grantId) => revokeGrant(deps, grantId), + revokeGrant: (grantId: string, tenantId?: string) => + revokeGrant(deps, grantId, tenantId), getPage: (pagination?, filter?, sortOrder?) => getGrantsPage(deps, pagination, filter, sortOrder), updateLastContinuedAt: (id) => updateLastContinuedAt(id), @@ -149,12 +156,17 @@ async function markPending( deps: ServiceDependencies, id: string, trx?: Transaction -): Promise { +): Promise { const grantTrx = trx || (await deps.knex.transaction()) try { - const grant = await Grant.query(trx).patchAndFetchById(id, { - state: GrantState.Pending - }) + const grant = await Grant.query(trx) + .patchAndFetchById(id, { + state: GrantState.Pending + }) + .withGraphFetched('tenant') + + if (!isGrantWithTenant(grant)) + throw new Error('required graph not returned in query') if (!trx) { await grantTrx.commit() @@ -176,20 +188,27 @@ async function finalize(id: string, reason: GrantFinalization): Promise { async function revokeGrant( deps: ServiceDependencies, - grantId: string + grantId: string, + tenantId?: string ): Promise { const { accessTokenService } = deps const trx = await deps.knex.transaction() try { - const grant = await Grant.query(trx) + const queryBuilder = Grant.query(trx) .patchAndFetchById(grantId, { state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) .first() + if (tenantId) { + queryBuilder.andWhere('tenantId', tenantId) + } + + const grant = await queryBuilder + if (!grant) { deps.logger.info( `Could not revoke grant corresponding to grantId: ${grantId}` @@ -211,6 +230,7 @@ async function revokeGrant( async function create( deps: ServiceDependencies, grantRequest: GrantRequest, + tenantId: string, trx?: Transaction ): Promise { const { accessService, knex } = deps @@ -233,7 +253,8 @@ async function create( clientNonce: interact?.finish?.nonce, client, continueId: v4(), - continueToken: generateToken() + continueToken: generateToken(), + tenantId } const grant = await Grant.query(grantTrx).insert(grantData) diff --git a/packages/auth/src/graphql/resolvers/grant.test.ts b/packages/auth/src/graphql/resolvers/grant.test.ts index 50afb44936..dd374b1c74 100644 --- a/packages/auth/src/graphql/resolvers/grant.test.ts +++ b/packages/auth/src/graphql/resolvers/grant.test.ts @@ -20,6 +20,8 @@ import { Grant, Grant as GrantModel } from '../../grant/model' import { getPageTests } from './page.test' import { createGrant } from '../../tests/grant' import { GraphQLErrorCode } from '../errors' +import { Tenant } from '../../tenant/model' +import { generateTenant } from '../../tests/tenant' const responseHandler = (query: ApolloQueryResult): GrantsConnection => { if (query.data) { @@ -32,12 +34,17 @@ const responseHandler = (query: ApolloQueryResult): GrantsConnection => { describe('Grant Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer + let tenant: Tenant beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insertAndFetch(generateTenant()) + }) + afterEach(async (): Promise => { await truncateTables(appContainer.knex) }) @@ -50,7 +57,7 @@ describe('Grant Resolvers', (): void => { describe('Grants Queries', (): void => { getPageTests({ getClient: () => appContainer.apolloClient, - createModel: () => createGrant(deps) as Promise, + createModel: () => createGrant(deps, tenant.id) as Promise, pagedQuery: 'grants' }) @@ -58,7 +65,7 @@ describe('Grant Resolvers', (): void => { const grants: GrantModel[] = [] for (let i = 0; i < 2; i++) { - grants[1 - i] = await createGrant(deps) + grants[1 - i] = await createGrant(deps, tenant.id) } const query = await appContainer.apolloClient @@ -106,7 +113,7 @@ describe('Grant Resolvers', (): void => { { identifier: 'https://abc.com/xyz' } ] for (const { identifier } of grantData) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) grants.push(grant) } }) @@ -170,7 +177,7 @@ describe('Grant Resolvers', (): void => { { identifier: 'https://abc.com/xyz' } ] for (const { identifier } of grantData) { - const grant = await createGrant(deps, { identifier }) + const grant = await createGrant(deps, tenant.id, { identifier }) grants.push(grant) } @@ -231,7 +238,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -280,7 +287,7 @@ describe('Grant Resolvers', (): void => { { state: GrantState.Approved } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -339,7 +346,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -402,7 +409,7 @@ describe('Grant Resolvers', (): void => { } ] for (const patch of grantPatches) { - const grant = await createGrant(deps) + const grant = await createGrant(deps, tenant.id) await grant.$query().patch(patch) } @@ -454,7 +461,7 @@ describe('Grant Resolvers', (): void => { describe('Grant By id Queries', (): void => { let grant: GrantModel beforeEach(async (): Promise => { - grant = await createGrant(deps) + grant = await createGrant(deps, tenant.id) }) test('Can get a grant', async (): Promise => { @@ -528,7 +535,7 @@ describe('Grant Resolvers', (): void => { describe('Revoke grant', (): void => { let grant: GrantModel beforeEach(async (): Promise => { - grant = await createGrant(deps) + grant = await createGrant(deps, tenant.id) }) test('Can revoke a grant', async (): Promise => { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index b997a29f6a..bb49692b73 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -139,6 +139,16 @@ export function initIocContainer( } ) + container.singleton( + 'tenantService', + async (deps: IocContract) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + } + ) + container.singleton('grantRoutes', async (deps: IocContract) => { return createGrantRoutes({ grantService: await deps.use('grantService'), @@ -146,6 +156,7 @@ export function initIocContainer( accessTokenService: await deps.use('accessTokenService'), accessService: await deps.use('accessService'), interactionService: await deps.use('interactionService'), + tenantService: await deps.use('tenantService'), logger: await deps.use('logger'), config: await deps.use('config') }) @@ -158,6 +169,7 @@ export function initIocContainer( accessService: await deps.use('accessService'), interactionService: await deps.use('interactionService'), grantService: await deps.use('grantService'), + tenantService: await deps.use('tenantService'), logger: await deps.use('logger'), config: await deps.use('config') }) @@ -221,16 +233,6 @@ export function initIocContainer( return new Redis(config.redisUrl, { tls: config.redisTls }) }) - container.singleton( - 'tenantService', - async (deps: IocContract) => { - return createTenantService({ - logger: await deps.use('logger'), - knex: await deps.use('knex') - }) - } - ) - return container } diff --git a/packages/auth/src/interaction/routes.test.ts b/packages/auth/src/interaction/routes.test.ts index 8b5a539cb4..e394625c3a 100644 --- a/packages/auth/src/interaction/routes.test.ts +++ b/packages/auth/src/interaction/routes.test.ts @@ -1,5 +1,5 @@ import { v4 } from 'uuid' -import * as crypto from 'crypto' +import crypto from 'crypto' import jestOpenAPI from 'jest-openapi' import { IocContract } from '@adonisjs/fold' import assert from 'assert' @@ -26,6 +26,8 @@ import { generateNonce } from '../shared/utils' import { GNAPErrorCode } from '../shared/gnapErrors' import { generateBaseGrant } from '../tests/grant' import { generateBaseInteraction } from '../tests/interaction' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' const BASE_GRANT_ACCESS = { type: AccessType.IncomingPayment, @@ -39,11 +41,15 @@ describe('Interaction Routes', (): void => { let interactionRoutes: InteractionRoutes let config: IAppConfig + let tenant: Tenant let grant: Grant let interaction: Interaction beforeEach(async (): Promise => { - grant = await Grant.query().insert(generateBaseGrant()) + tenant = await Tenant.query().insert(generateTenant()) + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -79,6 +85,34 @@ describe('Interaction Routes', (): void => { }) describe('Client - interaction start', (): void => { + test('Interaction start fails if tenant has no configured idp', async (): Promise => { + const unconfiguredTenant = await Tenant.query().insertAndFetch({ + idpConsentUrl: undefined, + idpSecret: undefined + }) + const grant = await Grant.query().insert( + generateBaseGrant({ tenantId: unconfiguredTenant.id }) + ) + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { id: interaction.id, nonce: interaction.nonce } + ) + + await expect(interactionRoutes.start(ctx)).rejects.toMatchObject({ + status: 500, + code: GNAPErrorCode.RequestDenied, + message: 'internal server error' + }) + }) test('Interaction start fails if interaction is invalid', async (): Promise => { const ctx = createContext( { @@ -100,6 +134,7 @@ describe('Interaction Routes', (): void => { test('Interaction start fails if grant is revoked', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -139,12 +174,12 @@ describe('Interaction Routes', (): void => { }, url: `/interact/${interaction.id}/${interaction.nonce}` }, - { id: interaction.id, nonce: interaction.nonce } + { id: interaction.id, nonce: interaction.nonce, tenantId: tenant.id } ) assert.ok(interaction.id) - - const redirectUrl = new URL(config.identityServerUrl) + assert.ok(tenant.idpConsentUrl) + const redirectUrl = new URL(tenant.idpConsentUrl) redirectUrl.searchParams.set('interactId', interaction.id) const redirectSpy = jest.spyOn(ctx, 'redirect') @@ -236,6 +271,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish interaction with revoked grant', async (): Promise => { const grant = await Grant.query().insert( generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) @@ -265,10 +301,12 @@ describe('Interaction Routes', (): void => { describe('Interactions for grant with finish method', (): void => { test('Can finish accepted interaction', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Approved - }) + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, + state: GrantState.Approved + }) + ) await Access.query().insert({ ...BASE_GRANT_ACCESS, @@ -325,7 +363,7 @@ describe('Interaction Routes', (): void => { test('Can finish rejected interaction', async (): Promise => { const grant = await Grant.query().insert({ - ...generateBaseGrant(), + ...generateBaseGrant({ tenantId: tenant.id }), state: GrantState.Finalized, finalizationReason: GrantFinalization.Rejected }) @@ -410,7 +448,7 @@ describe('Interaction Routes', (): void => { let grantWithoutFinish: Grant beforeEach(async (): Promise => { grantWithoutFinish = await Grant.query().insert( - generateBaseGrant({ noFinishMethod: true }) + generateBaseGrant({ noFinishMethod: true, tenantId: tenant.id }) ) await Access.query().insert({ @@ -448,6 +486,7 @@ describe('Interaction Routes', (): void => { test('Can finish rejected interaction', async (): Promise => { const grant = await Grant.query().insert({ ...generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }), state: GrantState.Finalized, @@ -487,6 +526,7 @@ describe('Interaction Routes', (): void => { test('Cannot finish invalid interaction', async (): Promise => { const grant = await Grant.query().insert({ ...generateBaseGrant({ + tenantId: tenant.id, noFinishMethod: true }), state: GrantState.Finalized, @@ -527,14 +567,17 @@ describe('Interaction Routes', (): void => { }) describe('IDP - Grant details', (): void => { + let tenant: Tenant let grant: Grant let access: Access let interaction: Interaction - beforeAll(async (): Promise => { - grant = await Grant.query().insert({ - ...generateBaseGrant() - }) + beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) + + grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) access = await Access.query().insertAndFetch({ ...BASE_GRANT_ACCESS, @@ -552,7 +595,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -581,7 +624,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -596,11 +639,13 @@ describe('Interaction Routes', (): void => { }) test('Cannot get grant details for revoked grant', async (): Promise => { - const revokedGrant = await Grant.query().insert({ - ...generateBaseGrant(), - state: GrantState.Finalized, - finalizationReason: GrantFinalization.Revoked - }) + const revokedGrant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, + state: GrantState.Finalized, + finalizationReason: GrantFinalization.Revoked + }) + ) const interaction = await Interaction.query().insert( generateBaseInteraction(revokedGrant) @@ -610,7 +655,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -644,13 +689,34 @@ describe('Interaction Routes', (): void => { }) }) + test('Cannot get grant details with invalid secret', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': 'wrong-secret' + }, + url: `/grant/${interaction.id}/${interaction.nonce}`, + method: 'GET' + }, + { id: interaction.id, nonce: interaction.nonce } + ) + + await expect(interactionRoutes.details(ctx)).rejects.toMatchObject({ + status: 401, + code: GNAPErrorCode.InvalidRequest, + message: 'invalid x-idp-secret' + }) + }) + test('Cannot get grant details for nonexistent interaction', async (): Promise => { const ctx = createContext( { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret }, url: `/grant/${interaction.id}/${interaction.nonce}`, method: 'GET' @@ -668,7 +734,7 @@ describe('Interaction Routes', (): void => { let pendingGrant: Grant beforeEach(async (): Promise => { pendingGrant = await Grant.query().insert({ - ...generateBaseGrant(), + ...generateBaseGrant({ tenantId: tenant.id }), state: GrantState.Pending }) @@ -678,6 +744,46 @@ describe('Interaction Routes', (): void => { }) }) + test('cannot accept/reject interaction with unconfigured tenant', async (): Promise => { + const unconfiguredTenant = await Tenant.query().insertAndFetch({ + idpConsentUrl: undefined, + idpSecret: undefined + }) + + const grant = await Grant.query().insert( + generateBaseGrant({ tenantId: unconfiguredTenant.id }) + ) + + const interaction = await Interaction.query().insert( + generateBaseInteraction(grant) + ) + + const ctx = createContext( + { + url: `/grant/${interaction.id}/${interaction.nonce}/accept`, + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': tenant.idpSecret + } + }, + { + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept + } + ) + + await expect( + interactionRoutes.acceptOrReject(ctx) + ).rejects.toMatchObject({ + status: 404, + code: GNAPErrorCode.UnknownInteraction, + message: 'unknown interaction' + }) + }) + test('cannot accept/reject interaction without secret', async (): Promise => { const ctx = createContext( { @@ -702,6 +808,31 @@ describe('Interaction Routes', (): void => { }) }) + test('cannot accept/reject interacetion with invalid secret', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-idp-secret': 'wrong-secret' + } + }, + { + id: interaction.id, + nonce: interaction.nonce, + choice: InteractionChoices.Accept + } + ) + + await expect( + interactionRoutes.acceptOrReject(ctx) + ).rejects.toMatchObject({ + status: 401, + code: GNAPErrorCode.InvalidInteraction, + message: 'invalid x-idp-secret' + }) + }) + test('can accept interaction', async (): Promise => { const ctx = createContext( { @@ -710,7 +841,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { @@ -744,7 +875,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { id: interactId, nonce } @@ -767,7 +898,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { @@ -800,7 +931,7 @@ describe('Interaction Routes', (): void => { headers: { Accept: 'application/json', 'Content-Type': 'application/json', - 'x-idp-secret': Config.identityServerSecret + 'x-idp-secret': tenant.idpSecret } }, { diff --git a/packages/auth/src/interaction/routes.ts b/packages/auth/src/interaction/routes.ts index 64ed405d6d..19d090ff5b 100644 --- a/packages/auth/src/interaction/routes.ts +++ b/packages/auth/src/interaction/routes.ts @@ -19,11 +19,14 @@ import { import { toOpenPaymentsAccess } from '../access/model' import { GNAPErrorCode, GNAPServerRouteError } from '../shared/gnapErrors' import { generateRouteLogs } from '../shared/utils' +import { TenantService } from '../tenant/service' +import { isTenantWithIdp } from '../tenant/model' interface ServiceDependencies extends BaseService { grantService: GrantService accessService: AccessService interactionService: InteractionService + tenantService: TenantService config: IAppConfig } @@ -83,6 +86,7 @@ export function createInteractionRoutes({ grantService, accessService, interactionService, + tenantService, logger, config }: ServiceDependencies): InteractionRoutes { @@ -94,6 +98,7 @@ export function createInteractionRoutes({ grantService, accessService, interactionService, + tenantService, logger: log, config } @@ -111,13 +116,32 @@ async function getGrantDetails( ctx: GetContext ): Promise { const secret = ctx.headers?.['x-idp-secret'] - const { config, interactionService, accessService } = deps + const { interactionService, accessService, tenantService } = deps const { id: interactId, nonce } = ctx.params + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction || isRevokedGrant(interaction.grant)) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } + + // Tenant should exist as it is a foreign key requirement on grants + const tenant = await tenantService.get(interaction.grant.tenantId) + if (!tenant || !isTenantWithIdp(tenant)) { + throw new GNAPServerRouteError( + 500, + GNAPErrorCode.InvalidRequest, + 'internal server error' + ) + } + if ( !secret || !crypto.timingSafeEqual( Buffer.from(secret as string), - Buffer.from(config.identityServerSecret) + Buffer.from(tenant.idpSecret) ) ) { throw new GNAPServerRouteError( @@ -126,14 +150,6 @@ async function getGrantDetails( 'invalid x-idp-secret' ) } - const interaction = await interactionService.getBySession(interactId, nonce) - if (!interaction || isRevokedGrant(interaction.grant)) { - throw new GNAPServerRouteError( - 404, - GNAPErrorCode.UnknownInteraction, - 'unknown interaction' - ) - } const access = await accessService.getByGrant(interaction.grantId) @@ -165,9 +181,8 @@ async function startInteraction( ) const { id: interactId, nonce } = ctx.params const { clientName, clientUri } = ctx.query - const { config, interactionService, grantService, logger } = deps + const { interactionService, grantService, logger } = deps const interaction = await interactionService.getBySession(interactId, nonce) - if ( !interaction || interaction.state !== InteractionState.Pending || @@ -182,12 +197,16 @@ async function startInteraction( const trx = await Interaction.startTransaction() try { - await grantService.markPending(interaction.id, trx) + // Grant and Tenant should exist, as one is a foreign key requirement on interactions and the other a foreign key requirement on that grant. + const grant = await grantService.markPending(interaction.grant.id, trx) await trx.commit() + if (!isTenantWithIdp(grant.tenant)) throw new Error('invalid interaction') + const { idpConsentUrl } = grant.tenant + ctx.session.nonce = interaction.nonce - const interactionUrl = new URL(config.identityServerUrl) + const interactionUrl = new URL(idpConsentUrl) interactionUrl.searchParams.set('interactId', interaction.id) interactionUrl.searchParams.set('nonce', interaction.nonce) interactionUrl.searchParams.set('clientName', clientName as string) @@ -219,14 +238,32 @@ async function handleInteractionChoice( ctx: ChooseContext ): Promise { const { id: interactId, nonce, choice } = ctx.params - const { config, interactionService, logger } = deps + const { interactionService, logger } = deps const secret = ctx.headers['x-idp-secret'] + const interaction = await interactionService.getBySession(interactId, nonce) + if (!interaction) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } + + const tenant = await deps.tenantService.get(interaction.grant.tenantId) + + if (!tenant || !isTenantWithIdp(tenant)) { + throw new GNAPServerRouteError( + 404, + GNAPErrorCode.UnknownInteraction, + 'unknown interaction' + ) + } if ( !secret || !crypto.timingSafeEqual( Buffer.from(secret as string), - Buffer.from(config.identityServerSecret) + Buffer.from(tenant.idpSecret) ) ) { throw new GNAPServerRouteError( @@ -236,67 +273,58 @@ async function handleInteractionChoice( ) } - const interaction = await interactionService.getBySession(interactId, nonce) - if (!interaction) { + const { grant } = interaction + // If grant was already rejected or revoked + if ( + grant.state === GrantState.Finalized && + grant.finalizationReason !== GrantFinalization.Issued + ) { throw new GNAPServerRouteError( - 404, - GNAPErrorCode.UnknownInteraction, - 'unknown interaction' + 401, + GNAPErrorCode.UserDenied, + 'user denied interaction' ) - } else { - const { grant } = interaction - // If grant was already rejected or revoked - if ( - grant.state === GrantState.Finalized && - grant.finalizationReason !== GrantFinalization.Issued - ) { - throw new GNAPServerRouteError( - 401, - GNAPErrorCode.UserDenied, - 'user denied interaction' - ) - } - - // If grant is otherwise not pending interaction - if ( - interaction.state !== InteractionState.Pending || - isInteractionExpired(interaction) - ) { - throw new GNAPServerRouteError( - 400, - GNAPErrorCode.InvalidInteraction, - 'invalid interaction' - ) - } - - if (choice === InteractionChoices.Accept) { - logger.debug( - { - ...generateRouteLogs(ctx), - interaction - }, - 'interaction approved' - ) - await interactionService.approve(interactId) - } else if (choice === InteractionChoices.Reject) { - logger.debug( - { - ...generateRouteLogs(ctx), - interaction - }, - 'interaction rejected' - ) - await interactionService.deny(interactId) - } else { - throw new GNAPServerRouteError( - 400, - GNAPErrorCode.InvalidRequest, - 'invalid interaction choice' - ) - } + } - ctx.status = 202 + // If grant is otherwise not pending interaction + if ( + interaction.state !== InteractionState.Pending || + isInteractionExpired(interaction) + ) { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidInteraction, + 'invalid interaction' + ) } + + if (choice === InteractionChoices.Accept) { + logger.debug( + { + ...generateRouteLogs(ctx), + interaction + }, + 'interaction approved' + ) + await interactionService.approve(interactId) + } else if (choice === InteractionChoices.Reject) { + logger.debug( + { + ...generateRouteLogs(ctx), + interaction + }, + 'interaction rejected' + ) + await interactionService.deny(interactId) + } else { + throw new GNAPServerRouteError( + 400, + GNAPErrorCode.InvalidRequest, + 'invalid interaction choice' + ) + } + + ctx.status = 202 } async function handleFinishableGrant( diff --git a/packages/auth/src/interaction/service.test.ts b/packages/auth/src/interaction/service.test.ts index 8e09a567d4..236650a9e2 100644 --- a/packages/auth/src/interaction/service.test.ts +++ b/packages/auth/src/interaction/service.test.ts @@ -17,6 +17,8 @@ import { Access } from '../access/model' import { Interaction, InteractionState } from './model' import { InteractionService } from './service' import { generateNonce, generateToken } from '../shared/utils' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' const CLIENT = faker.internet.url({ appendSlash: false }) const BASE_GRANT_ACCESS = { @@ -30,6 +32,7 @@ describe('Interaction Service', (): void => { let interactionService: InteractionService let interaction: Interaction let grant: Grant + let tenant: Tenant beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -39,6 +42,7 @@ describe('Interaction Service', (): void => { }) beforeEach(async (): Promise => { + tenant = await Tenant.query().insert(generateTenant()) grant = await Grant.query().insert({ state: GrantState.Processing, startMethod: [StartMethod.Redirect], @@ -47,7 +51,8 @@ describe('Interaction Service', (): void => { finishMethod: FinishMethod.Redirect, finishUri: 'https://example.com', clientNonce: generateNonce(), - client: CLIENT + client: CLIENT, + tenantId: tenant.id }) interaction = await Interaction.query().insert({ @@ -75,7 +80,9 @@ describe('Interaction Service', (): void => { describe('create', (): void => { test('can create an interaction', async (): Promise => { - const grant = await Grant.query().insert(generateBaseGrant()) + const grant = await Grant.query().insert( + generateBaseGrant({ tenantId: tenant.id }) + ) const interaction = await interactionService.create(grant.id) diff --git a/packages/auth/src/interaction/service.ts b/packages/auth/src/interaction/service.ts index 61f9606891..b68afa16b1 100644 --- a/packages/auth/src/interaction/service.ts +++ b/packages/auth/src/interaction/service.ts @@ -103,11 +103,12 @@ async function getBySession( id: string, nonce: string ): Promise { - const interaction = await Interaction.query() + const queryBuilder = Interaction.query() .findById(id) .where('nonce', nonce) .withGraphFetched('grant') + const interaction = await queryBuilder if (!interaction || !isInteractionWithGrant(interaction)) { return undefined } diff --git a/packages/auth/src/signature/middleware.test.ts b/packages/auth/src/signature/middleware.test.ts index df93af0bd5..595e3fefe0 100644 --- a/packages/auth/src/signature/middleware.test.ts +++ b/packages/auth/src/signature/middleware.test.ts @@ -36,6 +36,8 @@ import { ContinueContext, CreateContext } from '../grant/routes' import { Interaction, InteractionState } from '../interaction/model' import { generateNonce } from '../shared/utils' import { GNAPErrorCode } from '../shared/gnapErrors' +import { Tenant } from '../tenant/model' +import { generateTenant } from '../tests/tenant' describe('Signature Service', (): void => { let deps: IocContract @@ -66,6 +68,7 @@ describe('Signature Service', (): void => { let managementId: string let tokenManagementUrl: string let accessTokenService: AccessTokenService + let tenant: Tenant const generateBaseGrant = (overrides?: Partial) => ({ state: GrantState.Pending, @@ -112,7 +115,10 @@ describe('Signature Service', (): void => { }) beforeEach(async (): Promise => { - grant = await Grant.query(trx).insertAndFetch(generateBaseGrant()) + tenant = await Tenant.query(trx).insertAndFetch(generateTenant()) + grant = await Grant.query(trx).insertAndFetch( + generateBaseGrant({ tenantId: tenant.id }) + ) await Access.query(trx).insertAndFetch({ grantId: grant.id, ...BASE_ACCESS @@ -338,12 +344,13 @@ describe('Signature Service', (): void => { }) test('middleware fails if grant is revoked', async (): Promise => { - const grant = await Grant.query().insert({ - ...generateBaseGrant({ + const grant = await Grant.query().insert( + generateBaseGrant({ + tenantId: tenant.id, state: GrantState.Finalized, finalizationReason: GrantFinalization.Revoked }) - }) + ) const ctx = await createContextWithSigHeaders( { diff --git a/packages/auth/src/tenant/model.ts b/packages/auth/src/tenant/model.ts index 412422e217..74bbc22101 100644 --- a/packages/auth/src/tenant/model.ts +++ b/packages/auth/src/tenant/model.ts @@ -10,3 +10,12 @@ export class Tenant extends BaseModel { public deletedAt?: Date } + +export interface TenantWithIdp extends Tenant { + idpConsentUrl: NonNullable + idpSecret: NonNullable +} + +export function isTenantWithIdp(tenant: Tenant): tenant is TenantWithIdp { + return !!(tenant.idpConsentUrl && tenant.idpSecret) +} diff --git a/packages/auth/src/tests/grant.ts b/packages/auth/src/tests/grant.ts index 85b1f4db7e..6dee5c889d 100644 --- a/packages/auth/src/tests/grant.ts +++ b/packages/auth/src/tests/grant.ts @@ -16,6 +16,7 @@ const CLIENT = faker.internet.url({ appendSlash: false }) export async function createGrant( deps: IocContract, + tenantId: string, options?: { identifier?: string } ): Promise { const grantService = await deps.use('grantService') @@ -36,32 +37,38 @@ export async function createGrant( } } - return await grantService.create({ - ...BASE_GRANT_REQUEST, - access_token: { - access: [ - { - ...BASE_GRANT_ACCESS, - type: AccessType.IncomingPayment - } - ] - } - }) + return await grantService.create( + { + ...BASE_GRANT_REQUEST, + access_token: { + access: [ + { + ...BASE_GRANT_ACCESS, + type: AccessType.IncomingPayment + } + ] + } + }, + tenantId + ) } export interface GenerateBaseGrantOptions { + tenantId: string state?: GrantState finalizationReason?: GrantFinalization noFinishMethod?: boolean } -export const generateBaseGrant = (options: GenerateBaseGrantOptions = {}) => { +export const generateBaseGrant = (options: GenerateBaseGrantOptions) => { const { + tenantId, state = GrantState.Processing, finalizationReason = undefined, noFinishMethod = false } = options return { + tenantId, state, finalizationReason, startMethod: [StartMethod.Redirect], diff --git a/packages/auth/src/tests/tenant.ts b/packages/auth/src/tests/tenant.ts new file mode 100644 index 0000000000..5c146eeca6 --- /dev/null +++ b/packages/auth/src/tests/tenant.ts @@ -0,0 +1,11 @@ +import crypto from 'crypto' +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' + +export function generateTenant() { + return { + id: v4(), + idpConsentUrl: faker.internet.url(), + idpSecret: crypto.randomBytes(8).toString('base64') + } +} diff --git a/packages/backend/migrations/20241208214023_add_tenant_id_to_quote.js b/packages/backend/migrations/20241208214023_add_tenant_id_to_quote.js new file mode 100644 index 0000000000..2f16118d46 --- /dev/null +++ b/packages/backend/migrations/20241208214023_add_tenant_id_to_quote.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('quotes', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "quotes" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('quotes', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('quotes', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/migrations/20241223104532_add_tenant_id_to_outgoing_payments.js b/packages/backend/migrations/20241223104532_add_tenant_id_to_outgoing_payments.js new file mode 100644 index 0000000000..34092bbb49 --- /dev/null +++ b/packages/backend/migrations/20241223104532_add_tenant_id_to_outgoing_payments.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('outgoingPayments', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "outgoingPayments" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('outgoingPayments', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('outgoingPayments', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/migrations/20250120101610_add_tenant_to_incoming_payments.js b/packages/backend/migrations/20250120101610_add_tenant_to_incoming_payments.js new file mode 100644 index 0000000000..06c5c0dfd8 --- /dev/null +++ b/packages/backend/migrations/20250120101610_add_tenant_to_incoming_payments.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('incomingPayments', function (table) { + table.uuid('tenantId') + table.foreign('tenantId').references('id').inTable('tenants') + }) + .then(() => { + knex.raw( + `UPDATE "incomingPayments" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + knex.schema.alterTable('incomingPayments', function (table) { + table.uuid('tenantId').notNullable() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('incomingPayments', function (table) { + table.dropForeign('tenantId') + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e4ca164b97..7a6c6fa1ac 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -501,7 +501,7 @@ export class App { // POST /incoming-payments // Create incoming payment router.post>( - '/incoming-payments', + '/:tenantId/incoming-payments', createValidatorMiddleware< ContextType> >( @@ -528,7 +528,7 @@ export class App { DefaultState, SignedCollectionContext >( - '/incoming-payments', + '/:tenantId/incoming-payments', createValidatorMiddleware< ContextType> >( @@ -552,7 +552,7 @@ export class App { // POST /outgoing-payment // Create outgoing payment router.post>( - '/outgoing-payments', + '/:tenantId/outgoing-payments', createValidatorMiddleware< ContextType> >( @@ -579,7 +579,7 @@ export class App { DefaultState, SignedCollectionContext >( - '/outgoing-payments', + '/:tenantId/outgoing-payments', createValidatorMiddleware< ContextType> >( @@ -603,7 +603,7 @@ export class App { // POST /quotes // Create quote router.post>( - '/quotes', + '/:tenantId/quotes', createValidatorMiddleware< ContextType> >( @@ -627,7 +627,7 @@ export class App { // GET /incoming-payments/{id} // Read incoming payment router.get( - '/incoming-payments/:id', + '/:tenantId/incoming-payments/:id', createValidatorMiddleware< ContextType >( @@ -652,7 +652,7 @@ export class App { // POST /incoming-payments/{id}/complete // Complete incoming payment router.post( - '/incoming-payments/:id/complete', + '/:tenantId/incoming-payments/:id/complete', createValidatorMiddleware>( resourceServerSpec, { @@ -674,7 +674,7 @@ export class App { // GET /outgoing-payments/{id} // Read outgoing payment router.get( - '/outgoing-payments/:id', + '/:tenantId/outgoing-payments/:id', createValidatorMiddleware>( resourceServerSpec, { @@ -696,7 +696,7 @@ export class App { // GET /quotes/{id} // Read quote router.get( - '/quotes/:id', + '/:tenantId/quotes/:id', createValidatorMiddleware>( resourceServerSpec, { diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 4830e5ea91..1df71ad05c 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -717,6 +717,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "withdrawalThreshold", "description": "Minimum amount of liquidity that can be withdrawn from the asset.", @@ -1118,6 +1134,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the asset. 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": "withdrawalThreshold", "description": "Minimum amount of liquidity that can be withdrawn from the asset.", @@ -3581,6 +3609,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddressId", "description": "Unique identifier of the wallet address under which the incoming payment was created.", @@ -5604,6 +5644,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Tenant ID of the outgoing payment.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddressId", "description": "Unique identifier of the wallet address under which the outgoing payment was created.", @@ -6815,6 +6867,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": { @@ -6904,6 +6968,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": { @@ -7608,6 +7684,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant under which the quote was created.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddressId", "description": "Unique identifier of the wallet address under which the quote was created.", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 99ff7f0619..21d00b3971 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -611,6 +614,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1025,6 +1030,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1268,6 +1275,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1278,6 +1286,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1370,6 +1379,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -2236,6 +2247,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2359,6 +2371,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2460,6 +2473,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2569,6 +2583,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/backend/src/graphql/resolvers/asset.test.ts b/packages/backend/src/graphql/resolvers/asset.test.ts index 7768d29a12..8ee9e279bd 100644 --- a/packages/backend/src/graphql/resolvers/asset.test.ts +++ b/packages/backend/src/graphql/resolvers/asset.test.ts @@ -3,7 +3,11 @@ import assert from 'assert' import { v4 as uuid } from 'uuid' import { getPageTests } from './page.test' -import { createTestApp, TestContainer } from '../../tests/app' +import { + createApolloClient, + createTestApp, + TestContainer +} from '../../tests/app' import { IocContract } from '@adonisjs/fold' import { AppServices } from '../../app' import { initIocContainer } from '../..' @@ -32,6 +36,7 @@ import { isFeeError } from '../../fee/errors' import { createFee } from '../../tests/fee' import { createAsset } from '../../tests/asset' import { GraphQLErrorCode } from '../errors' +import { createTenant } from '../../tests/tenant' describe('Asset Resolvers', (): void => { let deps: IocContract @@ -212,6 +217,55 @@ describe('Asset Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant create', async (): Promise => { + const otherTenant = await createTenant(deps) + const badInputData = { + ...randomAsset(), + tenantId: uuid() + } + + const tenantedApolloClient = await createApolloClient( + appContainer.container, + appContainer.app, + otherTenant.id + ) + try { + expect.assertions(2) + await tenantedApolloClient + .mutate({ + mutation: gql` + mutation CreateAsset($input: CreateAssetInput!) { + createAsset(input: $input) { + asset { + id + } + } + } + `, + variables: { + input: badInputData + } + }) + .then((query): AssetMutationResponse => { + if (query.data) { + return query.data.createAsset + } 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('Asset Queries', (): void => { diff --git a/packages/backend/src/graphql/resolvers/asset.ts b/packages/backend/src/graphql/resolvers/asset.ts index c6862aa9b8..f324afaf25 100644 --- a/packages/backend/src/graphql/resolvers/asset.ts +++ b/packages/backend/src/graphql/resolvers/asset.ts @@ -7,7 +7,7 @@ import { } from '../generated/graphql' import { Asset } from '../../asset/model' import { errorToCode, errorToMessage, isAssetError } from '../../asset/errors' -import { TenantedApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { feeToGraphql } from './fee' @@ -23,14 +23,14 @@ export const getAssets: QueryResolvers['assets'] = const assets = await assetService.getPage({ pagination, sortOrder: order, - tenantId: ctx.tenant.id + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }) const pageInfo = await getPageInfo({ getPage: (pagination: Pagination, sortOrder?: SortOrder) => assetService.getPage({ pagination, sortOrder, - tenantId: ctx.tenant.id + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }), page: assets, sortOrder: order @@ -50,7 +50,10 @@ export const getAsset: QueryResolvers['asset'] = async ( ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.get(args.id, ctx.tenant.id) + const asset = await assetService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!asset) { throw new GraphQLError('Asset not found', { extensions: { @@ -72,16 +75,26 @@ export const getAssetByCodeAndScale: QueryResolvers['asse return asset ? assetToGraphql(asset) : null } -export const createAsset: MutationResolvers['createAsset'] = +export const createAsset: MutationResolvers['createAsset'] = async ( parent, args, ctx ): Promise => { + const tenantId = ctx.forTenantId + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) const assetService = await ctx.container.use('assetService') const assetOrError = await assetService.create({ ...args.input, - tenantId: ctx.tenant.id + tenantId }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { @@ -203,5 +216,6 @@ export const assetToGraphql = (asset: Asset): SchemaAsset => ({ scale: asset.scale, withdrawalThreshold: asset.withdrawalThreshold, liquidityThreshold: asset.liquidityThreshold, - createdAt: new Date(+asset.createdAt).toISOString() + createdAt: new Date(+asset.createdAt).toISOString(), + tenantId: asset.tenantId }) diff --git a/packages/backend/src/graphql/resolvers/combined_payments.test.ts b/packages/backend/src/graphql/resolvers/combined_payments.test.ts index 0f2550a1b9..2f3ac34706 100644 --- a/packages/backend/src/graphql/resolvers/combined_payments.test.ts +++ b/packages/backend/src/graphql/resolvers/combined_payments.test.ts @@ -56,6 +56,7 @@ describe('Payment', (): void => { const client = 'client-test' const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId, client: client, method: 'ilp', @@ -74,7 +75,8 @@ describe('Payment', (): void => { }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: inWalletAddressId, - client: client + client: client, + tenantId: Config.operatorTenantId }) const query = await appContainer.apolloClient @@ -164,6 +166,7 @@ describe('Payment', (): void => { const client = 'client-test-type-wallet-address' const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId, client: client, method: 'ilp', @@ -175,6 +178,7 @@ describe('Payment', (): void => { assetId: asset.id }) await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId2, client: client, method: 'ilp', diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts index 56c0c74ddd..77dfc66d96 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.test.ts @@ -38,6 +38,7 @@ describe('Incoming Payment Resolver', (): void => { let incomingPaymentService: IncomingPaymentService let accountingService: AccountingService let asset: Asset + let tenantId: string beforeAll(async (): Promise => { deps = await initIocContainer(Config) @@ -45,6 +46,7 @@ describe('Incoming Payment Resolver', (): void => { incomingPaymentService = await deps.use('incomingPaymentService') accountingService = await deps.use('accountingService') asset = await createAsset(deps) + tenantId = Config.operatorTenantId }) afterAll(async (): Promise => { @@ -78,7 +80,8 @@ describe('Incoming Payment Resolver', (): void => { metadata: { description: `IncomingPayment`, externalRef: '#123' - } + }, + tenantId }), pagedQuery: 'incomingPayments', parent: { @@ -118,7 +121,8 @@ describe('Incoming Payment Resolver', (): void => { client, metadata, expiresAt, - incomingAmount + incomingAmount, + tenantId }) const createSpy = jest @@ -167,7 +171,7 @@ describe('Incoming Payment Resolver', (): void => { query.data?.createIncomingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query).toEqual({ __typename: 'IncomingPaymentResponse', payment: { @@ -237,7 +241,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('Internal server error', async (): Promise => { @@ -282,7 +286,10 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId + }) }) }) @@ -299,7 +306,8 @@ describe('Incoming Payment Resolver', (): void => { value: BigInt(56), assetCode: asset.code, assetScale: asset.scale - } + }, + tenantId }) } @@ -475,7 +483,8 @@ describe('Incoming Payment Resolver', (): void => { walletAddressId, metadata, expiresAt, - incomingAmount + incomingAmount, + tenantId }) const input = { id: payment.id, @@ -510,7 +519,7 @@ describe('Incoming Payment Resolver', (): void => { query.data?.updateIncomingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query).toEqual({ __typename: 'IncomingPaymentResponse', payment: { @@ -565,7 +574,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('Internal server error', async (): Promise => { @@ -611,7 +620,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) }) }) diff --git a/packages/backend/src/graphql/resolvers/incoming_payment.ts b/packages/backend/src/graphql/resolvers/incoming_payment.ts index 778654dc2e..eb77bc7691 100644 --- a/packages/backend/src/graphql/resolvers/incoming_payment.ts +++ b/packages/backend/src/graphql/resolvers/incoming_payment.ts @@ -11,19 +11,20 @@ import { errorToCode, errorToMessage } from '../../open_payments/payment/incoming/errors' -import { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getIncomingPayment: QueryResolvers['incomingPayment'] = +export const getIncomingPayment: QueryResolvers['incomingPayment'] = async (parent, args, ctx): Promise => { const incomingPaymentService = await ctx.container.use( 'incomingPaymentService' ) const payment = await incomingPaymentService.get({ - id: args.id + id: args.id, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }) if (!payment) { throw new GraphQLError('payment does not exist', { @@ -35,7 +36,7 @@ export const getIncomingPayment: QueryResolvers['incomingPayment' return paymentToGraphql(payment) } -export const getWalletAddressIncomingPayments: WalletAddressResolvers['incomingPayments'] = +export const getWalletAddressIncomingPayments: WalletAddressResolvers['incomingPayments'] = async ( parent, args, @@ -56,14 +57,16 @@ export const getWalletAddressIncomingPayments: WalletAddressResolvers incomingPaymentService.getWalletAddressPage({ walletAddressId: parent.id as string, pagination, - sortOrder + sortOrder, + tenantId: ctx.tenant.id }), page: incomingPayments, sortOrder: order @@ -79,7 +82,7 @@ export const getWalletAddressIncomingPayments: WalletAddressResolvers['createIncomingPayment'] = +export const createIncomingPayment: MutationResolvers['createIncomingPayment'] = async ( parent, args, @@ -88,13 +91,20 @@ export const createIncomingPayment: MutationResolvers['createInco const incomingPaymentService = await ctx.container.use( 'incomingPaymentService' ) + + const tenantId = ctx.forTenantId + if (!tenantId) { + throw new Error('Missing tenant id to create incoming payment') + } + const incomingPaymentOrError = await incomingPaymentService.create({ walletAddressId: args.input.walletAddressId, expiresAt: !args.input.expiresAt ? undefined : new Date(args.input.expiresAt), incomingAmount: args.input.incomingAmount, - metadata: args.input.metadata + metadata: args.input.metadata, + tenantId }) if (isIncomingPaymentError(incomingPaymentOrError)) { throw new GraphQLError(errorToMessage[incomingPaymentOrError], { @@ -108,7 +118,7 @@ export const createIncomingPayment: MutationResolvers['createInco } } -export const updateIncomingPayment: MutationResolvers['updateIncomingPayment'] = +export const updateIncomingPayment: MutationResolvers['updateIncomingPayment'] = async ( parent, args, @@ -117,9 +127,10 @@ export const updateIncomingPayment: MutationResolvers['updateInco const incomingPaymentService = await ctx.container.use( 'incomingPaymentService' ) - const incomingPaymentOrError = await incomingPaymentService.update( - args.input - ) + const incomingPaymentOrError = await incomingPaymentService.update({ + ...args.input, + tenantId: ctx.tenant.id + }) if (isIncomingPaymentError(incomingPaymentOrError)) { throw new GraphQLError(errorToMessage[incomingPaymentOrError], { extensions: { @@ -132,7 +143,7 @@ export const updateIncomingPayment: MutationResolvers['updateInco } } -export const approveIncomingPayment: MutationResolvers['approveIncomingPayment'] = +export const approveIncomingPayment: MutationResolvers['approveIncomingPayment'] = async ( parent, args, @@ -143,7 +154,8 @@ export const approveIncomingPayment: MutationResolvers['approveIn ) const incomingPaymentOrError = await incomingPaymentService.approve( - args.input.id + args.input.id, + ctx.tenant.id ) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -159,7 +171,7 @@ export const approveIncomingPayment: MutationResolvers['approveIn } } -export const cancelIncomingPayment: MutationResolvers['cancelIncomingPayment'] = +export const cancelIncomingPayment: MutationResolvers['cancelIncomingPayment'] = async ( parent, args, @@ -170,7 +182,8 @@ export const cancelIncomingPayment: MutationResolvers['cancelInco ) const incomingPaymentOrError = await incomingPaymentService.cancel( - args.input.id + args.input.id, + ctx.tenant.id ) if (isIncomingPaymentError(incomingPaymentOrError)) { diff --git a/packages/backend/src/graphql/resolvers/index.ts b/packages/backend/src/graphql/resolvers/index.ts index 3e9641f352..303b10dbee 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -85,11 +85,7 @@ import { getTenant, getTenants } from './tenant' -import { - createTenantSettings, - deleteTenantSettings, - getTenantSettings -} from './tenant_settings' +import { createTenantSettings, getTenantSettings } from './tenant_settings' export const resolvers: Resolvers = { UInt8: GraphQLUInt8, @@ -184,7 +180,6 @@ export const resolvers: Resolvers = { createTenant, updateTenant, deleteTenant, - createTenantSettings, - deleteTenantSettings + createTenantSettings } } diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index 84df10f1fc..457099cb36 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -1743,13 +1743,15 @@ describe('Liquidity Resolvers', (): void => { ) describe('Event Liquidity', (): void => { + let tenantId: string let walletAddress: WalletAddress let incomingPayment: IncomingPayment let payment: OutgoingPayment beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId + tenantId }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { @@ -1759,9 +1761,11 @@ describe('Liquidity Resolvers', (): void => { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale }, - expiresAt: new Date(Date.now() + 60 * 1000) + expiresAt: new Date(Date.now() + 60 * 1000), + tenantId: Config.operatorTenantId }) payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, method: 'ilp', receiver: `${Config.openPaymentsUrl}/incoming-payments/${uuid()}`, @@ -2171,9 +2175,11 @@ describe('Liquidity Resolvers', (): void => { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale }, - expiresAt: new Date(Date.now() + 60 * 1000) + expiresAt: new Date(Date.now() + 60 * 1000), + tenantId: Config.operatorTenantId }) outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId, method: 'ilp', receiver: `${ diff --git a/packages/backend/src/graphql/resolvers/liquidity.ts b/packages/backend/src/graphql/resolvers/liquidity.ts index e402a8f8ac..14e93fcdd0 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.ts @@ -12,7 +12,7 @@ import { OutgoingPaymentResolvers, PaymentResolvers } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ApolloContext, TenantedApolloContext } from '../../app' import { fundingErrorToMessage, fundingErrorToCode, @@ -350,7 +350,7 @@ export type DepositEventType = OutgoingPaymentDepositType const isDepositEventType = (o: any): o is DepositEventType => Object.values(DepositEventType).includes(o) -export const depositEventLiquidity: MutationResolvers['depositEventLiquidity'] = +export const depositEventLiquidity: MutationResolvers['depositEventLiquidity'] = async ( parent, args, @@ -377,6 +377,7 @@ export const depositEventLiquidity: MutationResolvers['depositEve ) const paymentOrErr = await outgoingPaymentService.fund({ id: event.data.id, + tenantId: ctx.tenant.id, amount: BigInt(event.data.debitAmount.value), transferId: event.id }) @@ -434,7 +435,7 @@ export const withdrawEventLiquidity: MutationResolvers['withdrawE } } -export const depositOutgoingPaymentLiquidity: MutationResolvers['depositOutgoingPaymentLiquidity'] = +export const depositOutgoingPaymentLiquidity: MutationResolvers['depositOutgoingPaymentLiquidity'] = async ( parent, args, @@ -478,6 +479,7 @@ export const depositOutgoingPaymentLiquidity: MutationResolvers[' }) const paymentOrErr = await outgoingPaymentService.fund({ id: outgoingPaymentId, + tenantId: ctx.tenant.id, amount: BigInt(event.data.debitAmount.value), transferId: event.id }) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts index 00b4e0aa60..a4698992dc 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts @@ -68,6 +68,7 @@ describe('OutgoingPayment Resolvers', (): void => { const createPayment = async ( options: { + tenantId: string walletAddressId: string metadata?: Record }, @@ -94,12 +95,14 @@ describe('OutgoingPayment Resolvers', (): void => { describe('Query.outgoingPayment', (): void => { let payment: OutgoingPaymentModel + let tenantId: string let walletAddressId: string beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId walletAddressId = ( await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) ).id @@ -109,6 +112,7 @@ describe('OutgoingPayment Resolvers', (): void => { getClient: () => appContainer.apolloClient, createModel: () => createPayment({ + tenantId, walletAddressId }), pagedQuery: 'outgoingPayments' @@ -136,16 +140,16 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const firstReceiverWalletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) const secondWalletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) const secondReceiverWalletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) @@ -160,10 +164,12 @@ describe('OutgoingPayment Resolvers', (): void => { } const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: firstReceiverWalletAddress.id + walletAddressId: firstReceiverWalletAddress.id, + tenantId: Config.operatorTenantId }) receiver = incomingPayment.getUrl(firstReceiverWalletAddress) firstOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, receiver, method: 'ilp', @@ -171,10 +177,12 @@ describe('OutgoingPayment Resolvers', (): void => { }) const secondIncomingPayment = await createIncomingPayment(deps, { - walletAddressId: secondReceiverWalletAddress.id + walletAddressId: secondReceiverWalletAddress.id, + tenantId: Config.operatorTenantId }) const secondReceiver = secondIncomingPayment.getUrl(secondWalletAddress) secondOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: secondWalletAddress.id, receiver: secondReceiver, method: 'ilp', @@ -330,11 +338,14 @@ describe('OutgoingPayment Resolvers', (): void => { const grantId = uuid() const { id: walletAddressId } = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId }, grantId) + const payment = await createPayment( + { tenantId, walletAddressId }, + grantId + ) const query = await appContainer.apolloClient .query({ @@ -370,10 +381,10 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) - payment = await createPayment({ walletAddressId, metadata }) + payment = await createPayment({ tenantId, walletAddressId, metadata }) }) // Query with each payment state with and without an error @@ -552,17 +563,26 @@ describe('OutgoingPayment Resolvers', (): void => { }) describe('Mutation.createOutgoingPayment', (): void => { + let tenantId: string const metadata = { description: 'rent', externalRef: '202201' } + beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId + }) + test('success (metadata)', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId, metadata }) + const payment = await createPayment({ + tenantId, + walletAddressId, + metadata + }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -593,7 +613,7 @@ describe('OutgoingPayment Resolvers', (): void => { (query): OutgoingPaymentResponse => query.data?.createOutgoingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query.payment?.id).toBe(payment.id) expect(query.payment?.state).toBe(SchemaPaymentState.Funding) }) @@ -641,7 +661,7 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('internal server error', async (): Promise => { @@ -687,19 +707,27 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) }) describe('Mutation.createOutgoingPaymentFromIncomingPayment', (): void => { + let tenantId: string const mockIncomingPaymentUrl = `https://${faker.internet.domainName()}/incoming-payments/${uuid()}` + beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId + }) + test('create', async (): Promise => { const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId: walletAddress.id }) + const payment = await createPayment({ + tenantId, + walletAddressId: walletAddress.id + }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -736,7 +764,7 @@ describe('OutgoingPayment Resolvers', (): void => { query.data?.createOutgoingPaymentFromIncomingPayment ) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) expect(query.payment?.id).toBe(payment.id) expect(query.payment?.state).toBe(SchemaPaymentState.Funding) }) @@ -789,7 +817,7 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('unknown error', async (): Promise => { @@ -840,19 +868,23 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) }) describe('Mutation.cancelOutgoingPayment', (): void => { let payment: OutgoingPaymentModel beforeEach(async () => { + const tenantId = Config.operatorTenantId const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) - payment = await createPayment({ walletAddressId: walletAddress.id }) + payment = await createPayment({ + tenantId, + walletAddressId: walletAddress.id + }) }) const reasons: (string | undefined)[] = [undefined, 'Not enough balance'] @@ -895,7 +927,10 @@ describe('OutgoingPayment Resolvers', (): void => { query.data?.cancelOutgoingPayment ) - expect(cancelSpy).toHaveBeenCalledWith(input) + expect(cancelSpy).toHaveBeenCalledWith({ + ...input, + tenantId: payment.quote.tenantId + }) expect(mutationResponse.payment).toEqual({ __typename: 'OutgoingPayment', id: input.id, @@ -954,18 +989,23 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(cancelSpy).toHaveBeenCalledWith(input) + expect(cancelSpy).toHaveBeenCalledWith({ + ...input, + tenantId: payment.quote.tenantId + }) } ) }) describe('Wallet address outgoingPayments', (): void => { let walletAddressId: string + let tenantId: string beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId walletAddressId = ( await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) ).id @@ -975,6 +1015,7 @@ describe('OutgoingPayment Resolvers', (): void => { getClient: () => appContainer.apolloClient, createModel: () => createPayment({ + tenantId, walletAddressId }), pagedQuery: 'outgoingPayments', diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index a9cdcb4403..7eb85b86ee 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -12,19 +12,20 @@ import { errorToCode } from '../../open_payments/payment/outgoing/errors' import { OutgoingPayment } from '../../open_payments/payment/outgoing/model' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getOutgoingPayment: QueryResolvers['outgoingPayment'] = +export const getOutgoingPayment: QueryResolvers['outgoingPayment'] = async (parent, args, ctx): Promise => { const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) const payment = await outgoingPaymentService.get({ - id: args.id + id: args.id, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id }) if (!payment) { throw new GraphQLError('payment does not exist', { @@ -36,7 +37,7 @@ export const getOutgoingPayment: QueryResolvers['outgoingPayment' return paymentToGraphql(payment) } -export const getOutgoingPayments: QueryResolvers['outgoingPayments'] = +export const getOutgoingPayments: QueryResolvers['outgoingPayments'] = async ( parent, args, @@ -45,10 +46,11 @@ export const getOutgoingPayments: QueryResolvers['outgoingPayment const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) - const { filter, sortOrder, ...pagination } = args + const { tenantId, filter, sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc const getPageFn = (pagination_: Pagination, sortOrder_?: SortOrder) => outgoingPaymentService.getPage({ + tenantId: ctx.isOperator ? tenantId : ctx.tenant.id, pagination: pagination_, filter, sortOrder: sortOrder_ @@ -71,7 +73,7 @@ export const getOutgoingPayments: QueryResolvers['outgoingPayment } } -export const cancelOutgoingPayment: MutationResolvers['cancelOutgoingPayment'] = +export const cancelOutgoingPayment: MutationResolvers['cancelOutgoingPayment'] = async ( parent, args, @@ -81,9 +83,21 @@ export const cancelOutgoingPayment: MutationResolvers['cancelOutg 'outgoingPaymentService' ) - const outgoingPaymentOrError = await outgoingPaymentService.cancel( - args.input - ) + const tenantId = ctx.tenant.id + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + + const outgoingPaymentOrError = await outgoingPaymentService.cancel({ + tenantId, + ...args.input + }) if (isOutgoingPaymentError(outgoingPaymentOrError)) { throw new GraphQLError(errorToMessage[outgoingPaymentOrError], { @@ -98,7 +112,7 @@ export const cancelOutgoingPayment: MutationResolvers['cancelOutg } } -export const createOutgoingPayment: MutationResolvers['createOutgoingPayment'] = +export const createOutgoingPayment: MutationResolvers['createOutgoingPayment'] = async ( parent, args, @@ -107,9 +121,21 @@ export const createOutgoingPayment: MutationResolvers['createOutg const outgoingPaymentService = await ctx.container.use( 'outgoingPaymentService' ) - const outgoingPaymentOrError = await outgoingPaymentService.create( - args.input - ) + + const tenantId = ctx.tenant.id + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + const outgoingPaymentOrError = await outgoingPaymentService.create({ + tenantId, + ...args.input + }) if (isOutgoingPaymentError(outgoingPaymentOrError)) { throw new GraphQLError(errorToMessage[outgoingPaymentOrError], { extensions: { @@ -122,7 +148,7 @@ export const createOutgoingPayment: MutationResolvers['createOutg } } -export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['createOutgoingPaymentFromIncomingPayment'] = +export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['createOutgoingPaymentFromIncomingPayment'] = async ( parent, args, @@ -131,10 +157,20 @@ export const createOutgoingPaymentFromIncomingPayment: MutationResolvers['outgoingPayments'] = +export const getWalletAddressOutgoingPayments: WalletAddressResolvers['outgoingPayments'] = async ( parent, args, @@ -167,17 +203,20 @@ export const getWalletAddressOutgoingPayments: WalletAddressResolvers outgoingPaymentService.getWalletAddressPage({ walletAddressId: parent.id as string, pagination, - sortOrder + sortOrder, + tenantId }), page: outgoingPayments, sortOrder: order @@ -208,6 +247,7 @@ export function paymentToGraphql( metadata: payment.metadata, createdAt: new Date(+payment.createdAt).toISOString(), quote: quoteToGraphql(payment.quote), - grantId: payment.grantId + grantId: payment.grantId, + tenantId: payment.tenantId } } diff --git a/packages/backend/src/graphql/resolvers/quote.test.ts b/packages/backend/src/graphql/resolvers/quote.test.ts index 5132b5292b..246f7ac486 100644 --- a/packages/backend/src/graphql/resolvers/quote.test.ts +++ b/packages/backend/src/graphql/resolvers/quote.test.ts @@ -28,6 +28,7 @@ describe('Quote Resolvers', (): void => { let appContainer: TestContainer let quoteService: QuoteService let asset: Asset + let tenantId: string const receivingWalletAddress = 'http://wallet2.example/bob' const receiver = `${receivingWalletAddress}/incoming-payments/${uuid()}` @@ -39,6 +40,7 @@ describe('Quote Resolvers', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId asset = await createAsset(deps) }) @@ -56,6 +58,7 @@ describe('Quote Resolvers', (): void => { walletAddressId: string ): Promise => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount: { @@ -71,7 +74,7 @@ describe('Quote Resolvers', (): void => { describe('Query.quote', (): void => { test('success', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) const quote = await createWalletAddressQuote(walletAddressId) @@ -140,7 +143,9 @@ describe('Quote Resolvers', (): void => { } } `, - variables: { quoteId: uuid() } + variables: { + quoteId: uuid() + } }) } catch (error) { expect(error).toBeInstanceOf(ApolloError) @@ -190,7 +195,7 @@ describe('Quote Resolvers', (): void => { `('$type', async ({ withAmount, receiveAmount }): Promise => { const amount = withAmount ? debitAmount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) const input = { @@ -206,6 +211,7 @@ describe('Quote Resolvers', (): void => { .mockImplementationOnce(async (opts) => { quote = await createQuote(deps, { ...opts, + tenantId, validDestination: false }) return quote @@ -226,7 +232,11 @@ describe('Quote Resolvers', (): void => { }) .then((query): QuoteResponse => query.data?.createQuote) - expect(createSpy).toHaveBeenCalledWith({ ...input, method: 'ilp' }) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId, + method: 'ilp' + }) expect(query.quote?.id).toBe(quote?.id) }) @@ -292,7 +302,11 @@ describe('Quote Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith({ ...input, method: 'ilp' }) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId, + method: 'ilp' + }) }) }) @@ -302,7 +316,7 @@ describe('Quote Resolvers', (): void => { beforeEach(async (): Promise => { walletAddressId = ( await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) ).id diff --git a/packages/backend/src/graphql/resolvers/quote.ts b/packages/backend/src/graphql/resolvers/quote.ts index 16bd2863e1..f506cf221b 100644 --- a/packages/backend/src/graphql/resolvers/quote.ts +++ b/packages/backend/src/graphql/resolvers/quote.ts @@ -11,21 +11,22 @@ import { errorToMessage } from '../../open_payments/quote/errors' import { Quote } from '../../open_payments/quote/model' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { CreateQuoteOptions } from '../../open_payments/quote/service' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getQuote: QueryResolvers['quote'] = async ( +export const getQuote: QueryResolvers['quote'] = async ( parent, args, ctx ): Promise => { const quoteService = await ctx.container.use('quoteService') const quote = await quoteService.get({ - id: args.id + id: args.id, + tenantId: ctx.tenant.id }) if (!quote) { throw new GraphQLError('quote does not exist', { @@ -37,10 +38,21 @@ export const getQuote: QueryResolvers['quote'] = async ( return quoteToGraphql(quote) } -export const createQuote: MutationResolvers['createQuote'] = +export const createQuote: MutationResolvers['createQuote'] = async (parent, args, ctx): Promise => { const quoteService = await ctx.container.use('quoteService') + const tenantId = ctx.tenant.id + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) const options: CreateQuoteOptions = { + tenantId, walletAddressId: args.input.walletAddressId, receiver: args.input.receiver, method: 'ilp' @@ -61,7 +73,7 @@ export const createQuote: MutationResolvers['createQuote'] = } } -export const getWalletAddressQuotes: WalletAddressResolvers['quotes'] = +export const getWalletAddressQuotes: WalletAddressResolvers['quotes'] = async (parent, args, ctx): Promise => { if (!parent.id) { throw new GraphQLError('missing wallet address id', { @@ -73,17 +85,20 @@ export const getWalletAddressQuotes: WalletAddressResolvers['quot const quoteService = await ctx.container.use('quoteService') const { sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const tenantId = ctx.isOperator ? undefined : ctx.tenant.id const quotes = await quoteService.getWalletAddressPage({ walletAddressId: parent.id, pagination, - sortOrder: order + sortOrder: order, + tenantId }) const pageInfo = await getPageInfo({ getPage: (pagination: Pagination, sortOrder?: SortOrder) => quoteService.getWalletAddressPage({ walletAddressId: parent.id as string, pagination, - sortOrder + sortOrder, + tenantId }), page: quotes, sortOrder: order @@ -100,6 +115,7 @@ export const getWalletAddressQuotes: WalletAddressResolvers['quot export function quoteToGraphql(quote: Quote): SchemaQuote { return { id: quote.id, + tenantId: quote.tenantId, walletAddressId: quote.walletAddressId, receiver: quote.receiver, debitAmount: quote.debitAmount, diff --git a/packages/backend/src/graphql/resolvers/receiver.test.ts b/packages/backend/src/graphql/resolvers/receiver.test.ts index caa2cb32ef..8485a346be 100644 --- a/packages/backend/src/graphql/resolvers/receiver.test.ts +++ b/packages/backend/src/graphql/resolvers/receiver.test.ts @@ -106,7 +106,10 @@ describe('Receiver Resolver', (): void => { }) .then((query): CreateReceiverResponse => query.data?.createReceiver) - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId: Config.operatorTenantId + }) expect(query).toEqual({ __typename: 'CreateReceiverResponse', receiver: { @@ -187,7 +190,10 @@ describe('Receiver Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId: Config.operatorTenantId + }) }) test('returns error if error thrown when creating receiver', async (): Promise => { @@ -244,7 +250,10 @@ describe('Receiver Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId: Config.operatorTenantId + }) }) }) diff --git a/packages/backend/src/graphql/resolvers/receiver.ts b/packages/backend/src/graphql/resolvers/receiver.ts index b9002c30f1..5ab7e76ee3 100644 --- a/packages/backend/src/graphql/resolvers/receiver.ts +++ b/packages/backend/src/graphql/resolvers/receiver.ts @@ -4,7 +4,7 @@ import { Receiver as SchemaReceiver, QueryResolvers } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ApolloContext, TenantedApolloContext } from '../../app' import { Receiver } from '../../open_payments/receiver/model' import { isReceiverError, @@ -32,17 +32,23 @@ export const getReceiver: QueryResolvers['receiver'] = async ( return receiverToGraphql(receiver) } -export const createReceiver: MutationResolvers['createReceiver'] = +export const createReceiver: MutationResolvers['createReceiver'] = async (_, args, ctx): Promise => { const receiverService = await ctx.container.use('receiverService') + const tenantId = ctx.tenant.id + if (!tenantId) { + throw new Error('Tenant id is required to create a receiver') + } + const receiverOrError = await receiverService.create({ walletAddressUrl: args.input.walletAddressUrl, expiresAt: args.input.expiresAt ? new Date(args.input.expiresAt) : undefined, incomingAmount: args.input.incomingAmount, - metadata: args.input.metadata + metadata: args.input.metadata, + tenantId }) if (isReceiverError(receiverOrError)) { diff --git a/packages/backend/src/graphql/resolvers/tenant.ts b/packages/backend/src/graphql/resolvers/tenant.ts index 19e86bd077..6fdddf16da 100644 --- a/packages/backend/src/graphql/resolvers/tenant.ts +++ b/packages/backend/src/graphql/resolvers/tenant.ts @@ -40,7 +40,7 @@ export const getTenant: QueryResolvers['tenant'] = } const tenantService = await ctx.container.use('tenantService') - const tenant = await tenantService.get(args.id) + const tenant = await tenantService.get(args.id, isOperator) if (!tenant) { throw new GraphQLError('tenant does not exist', { extensions: { diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.test.ts b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts index abdd00cc0f..c4afbd6732 100644 --- a/packages/backend/src/graphql/resolvers/tenant_settings.test.ts +++ b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts @@ -7,7 +7,7 @@ import { truncateTables } from '../../tests/tableManager' import { createTenant } from '../../tests/tenant' import { CreateTenantSettingsInput, - CreateTenantSettingsMutationResponse, + CreateTenantSettingsMutationResponse } from '../generated/graphql' import { ApolloClient, @@ -91,7 +91,9 @@ describe('Tenant Settings Resolvers', (): void => { describe('Create Tenant Settings', (): void => { test('can create tenant setting', async (): Promise => { const input: CreateTenantSettingsInput = { - settings: [{ key: TenantSettingKeys.EXCHANGE_RATES_URL.name, value: 'MY_VALUE' }] + settings: [ + { key: TenantSettingKeys.EXCHANGE_RATES_URL.name, value: 'MY_VALUE' } + ] } const tenant = await createTenant(deps) diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.ts b/packages/backend/src/graphql/resolvers/tenant_settings.ts index 82510ab9f0..37dfa2565b 100644 --- a/packages/backend/src/graphql/resolvers/tenant_settings.ts +++ b/packages/backend/src/graphql/resolvers/tenant_settings.ts @@ -1,4 +1,3 @@ -import { GraphQLError } from 'graphql' import { TenantedApolloContext } from '../../app' import { Pagination } from '../../shared/baseModel' import { getPageInfo } from '../../shared/pagination' @@ -10,7 +9,6 @@ import { TenantSetting as SchemaTenantSetting, MutationResolvers } from '../generated/graphql' -import { GraphQLErrorCode } from '../errors' export const getTenantSettings: TenantResolvers['settings'] = async ( diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 5ec09c1c3e..c8ae3bfdb6 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -97,6 +97,8 @@ type Query { sortOrder: SortOrder "Filter outgoing payments based on specific criteria such as receiver, wallet address ID, or state." filter: OutgoingPaymentFilter + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): OutgoingPaymentConnection! "Fetch an Open Payments incoming payment by its ID." @@ -135,6 +137,8 @@ type Query { sortOrder: SortOrder "Filter payment events based on specific criteria such as payment type or wallet address ID." filter: PaymentFilter + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): PaymentConnection! "Fetch a paginated list of accounting transfers for a given account." @@ -394,6 +398,8 @@ input CreateAssetInput { liquidityThreshold: UInt64 "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String + "Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: ID } input UpdateAssetInput { @@ -685,6 +691,7 @@ type Asset implements Model { ): FeesConnection "The date and time when the asset was created." createdAt: String! + tenantId: ID! } enum SortOrder { @@ -938,6 +945,8 @@ type IncomingPayment implements BasePayment & Model { metadata: JSONObject "The date and time that the incoming payment was created." createdAt: String! + "The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature." + tenantId: String } type Receiver { @@ -1035,6 +1044,8 @@ type OutgoingPayment implements BasePayment & Model { createdAt: String! "Unique identifier of the grant under which the outgoing payment was created." grantId: String + "Tenant ID of the outgoing payment." + tenantId: String } enum OutgoingPaymentState { @@ -1158,6 +1169,8 @@ type QuoteEdge { type Quote { "Unique identifier of the quote." id: ID! + "Unique identifier of the tenant under which the quote was created." + tenantId: ID! "Unique identifier of the wallet address under which the quote was created." walletAddressId: ID! "Wallet address URL of the receiver." diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 306b6d895f..0dcde43128 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -178,7 +178,7 @@ export function initIocContainer( const authLink = setContext((request, { headers }) => { if (!config.authAdminApiSecret || !config.authAdminApiSignatureVersion) return { headers } - const timestamp = Math.round(new Date().getTime() / 1000) + const timestamp = Date.now() const version = config.authAdminApiSignatureVersion const { query, variables, operationName } = request 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 d9907f6084..60251049ec 100644 --- a/packages/backend/src/open_payments/payment/combined/service.test.ts +++ b/packages/backend/src/open_payments/payment/combined/service.test.ts @@ -27,6 +27,7 @@ describe('Combined Payment Service', (): void => { let appContainer: TestContainer let knex: Knex let combinedPaymentService: CombinedPaymentService + let tenantId: string let sendAsset: Asset let sendWalletAddressId: string let receiveAsset: Asset @@ -37,6 +38,7 @@ describe('Combined Payment Service', (): void => { appContainer = await createTestApp(deps) knex = appContainer.knex combinedPaymentService = await deps.use('combinedPaymentService') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { @@ -64,11 +66,13 @@ describe('Combined Payment Service', (): void => { async function setupPayments(deps: IocContract) { const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receiveWalletAddress.id + walletAddressId: receiveWalletAddress.id, + tenantId: Config.operatorTenantId }) const receiverUrl = incomingPayment.getUrl(receiveWalletAddress) const outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: sendWalletAddressId, method: 'ilp', receiver: receiverUrl, 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 e25f524449..a71cf23d4b 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.test.ts @@ -48,14 +48,15 @@ describe('Models', (): void => { baseUrl = new URL(walletAddress.url).origin incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id, - metadata: { description: 'my payment' } + metadata: { description: 'my payment' }, + tenantId: walletAddress.tenantId }) }) describe('toOpenPaymentsType', () => { test('returns incoming payment', async () => { expect(incomingPayment.toOpenPaymentsType(walletAddress)).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, walletAddress: walletAddress.url, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), @@ -83,7 +84,7 @@ describe('Models', (): void => { streamCredentials ) ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, walletAddress: walletAddress.url, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), @@ -108,7 +109,7 @@ describe('Models', (): void => { expect( incomingPayment.toOpenPaymentsTypeWithMethods(walletAddress) ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, walletAddress: walletAddress.url, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), @@ -139,7 +140,7 @@ describe('Models', (): void => { streamCredentials ) ).toEqual({ - id: `${baseUrl}${IncomingPayment.urlPath}/${incomingPayment.id}`, + id: `${baseUrl}/${Config.operatorTenantId}${IncomingPayment.urlPath}/${incomingPayment.id}`, walletAddress: walletAddress.url, completed: incomingPayment.completed, receivedAmount: serializeAmount(incomingPayment.receivedAmount), diff --git a/packages/backend/src/open_payments/payment/incoming/model.ts b/packages/backend/src/open_payments/payment/incoming/model.ts index f68a254357..4739efc9dc 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.ts @@ -110,6 +110,7 @@ export class IncomingPayment private incomingAmountValue?: bigint | null private receivedAmountValue?: bigint + public readonly tenantId!: string public get completed(): boolean { return this.state === IncomingPaymentState.Completed @@ -144,7 +145,7 @@ export class IncomingPayment public getUrl(walletAddress: WalletAddress): string { const url = new URL(walletAddress.url) - return `${url.origin}${IncomingPayment.urlPath}/${this.id}` + return `${url.origin}/${walletAddress.tenantId}${IncomingPayment.urlPath}/${this.id}` } public async onCredit({ 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 3a92e10ee9..06b0c69193 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.test.ts @@ -30,6 +30,7 @@ describe('Incoming Payment Routes', (): void => { let config: IAppConfig let incomingPaymentRoutes: IncomingPaymentRoutes let incomingPaymentService: IncomingPaymentService + let tenantId: string beforeAll(async (): Promise => { config = Config @@ -37,6 +38,7 @@ describe('Incoming Payment Routes', (): void => { appContainer = await createTestApp(deps) const { resourceServerSpec } = await deps.use('openApi') jestOpenAPI(resourceServerSpec) + tenantId = Config.operatorTenantId }) let asset: Asset @@ -54,7 +56,7 @@ describe('Incoming Payment Routes', (): void => { expiresAt = new Date(Date.now() + 30_000) asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) baseUrl = new URL(walletAddress.url).origin @@ -86,7 +88,8 @@ describe('Incoming Payment Routes', (): void => { client, expiresAt, incomingAmount, - metadata + metadata, + tenantId }), get: (ctx) => incomingPaymentRoutes.get(ctx as ReadContextWithAuthenticatedStatus), @@ -129,10 +132,11 @@ describe('Incoming Payment Routes', (): void => { 'returns incoming payment with empty methods if payment state is %s', async (paymentState): Promise => { const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId + tenantId }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId }) await incomingPayment.$query().update({ state: paymentState }) @@ -153,7 +157,44 @@ describe('Incoming Payment Routes', (): void => { expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toMatchObject({ methods: [] }) } - ) + ), + test('by tenantId', async () => { + const walletAddress = await createWalletAddress(deps, { + tenantId + }) + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: walletAddress.id, + tenantId + }) + + const ctx = setup({ + reqOpts: { + headers: { Accept: 'application/json' }, + method: 'GET', + url: `/incoming-payments/${incomingPayment.id}` + }, + params: { + id: incomingPayment.id, + tenantId + }, + walletAddress + }) + + await expect(incomingPaymentRoutes.get(ctx)).resolves.toBeUndefined() + + expect(ctx.response).toSatisfyApiSpec() + expect(ctx.body).toMatchObject({ + methods: [ + { + type: 'ilp', + ilpAddress: expect.stringMatching( + /^test\.rafiki\.[a-zA-Z0-9_-]{95}$/ + ), + sharedSecret: expect.any(String) + } + ] + }) + }) }) describe('create', (): void => { @@ -172,7 +213,10 @@ describe('Incoming Payment Routes', (): void => { async (error): Promise => { const ctx = setup>({ reqOpts: { body: {} }, - walletAddress + walletAddress, + params: { + tenantId + } }) const createSpy = jest .spyOn(incomingPaymentService, 'create') @@ -182,7 +226,8 @@ describe('Incoming Payment Routes', (): void => { status: errorToHTTPCode[error] }) expect(createSpy).toHaveBeenCalledWith({ - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId }) } ) @@ -200,6 +245,9 @@ describe('Incoming Payment Routes', (): void => { expiresAt }): Promise => { const ctx = setup>({ + params: { + tenantId + }, reqOpts: { body: { incomingAmount: incomingAmount ? amount : undefined, @@ -220,7 +268,8 @@ describe('Incoming Payment Routes', (): void => { incomingAmount: incomingAmount ? parseAmount(amount) : undefined, metadata, expiresAt: expiresAt ? new Date(expiresAt) : undefined, - client + client, + tenantId }) expect(ctx.response).toSatisfyApiSpec() const incomingPaymentId = ( @@ -230,7 +279,7 @@ describe('Incoming Payment Routes', (): void => { .pop() expect(ctx.response.body).toEqual({ - id: `${baseUrl}/incoming-payments/${incomingPaymentId}`, + id: `${baseUrl}/${tenantId}/incoming-payments/${incomingPaymentId}`, walletAddress: walletAddress.url, incomingAmount: incomingAmount ? amount : undefined, expiresAt: expiresAt || expect.any(String), @@ -264,7 +313,8 @@ describe('Incoming Payment Routes', (): void => { walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) }) test('returns 200 with an updated open payments incoming payment', async (): Promise => { @@ -275,7 +325,8 @@ describe('Incoming Payment Routes', (): void => { url: `/incoming-payments/${incomingPayment.id}/complete` }, params: { - id: incomingPayment.id + id: incomingPayment.id, + tenantId }, walletAddress }) @@ -309,7 +360,8 @@ describe('Incoming Payment Routes', (): void => { walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) const ctx = setup({ @@ -319,7 +371,8 @@ describe('Incoming Payment Routes', (): void => { url: `/incoming-payments/${incomingPayment.id}` }, params: { - id: incomingPayment.id + id: incomingPayment.id, + tenantId }, walletAddress }) @@ -328,7 +381,7 @@ describe('Incoming Payment Routes', (): void => { await expect(incomingPaymentRoutes.get(ctx)).resolves.toBeUndefined() expect(ctx.response).toSatisfyApiSpec() expect(ctx.body).toEqual({ - authServer: config.authServerGrantUrl, + authServer: config.authServerGrantUrl + '/' + incomingPayment.tenantId, receivedAmount: { value: '0', assetCode: asset.code, diff --git a/packages/backend/src/open_payments/payment/incoming/routes.ts b/packages/backend/src/open_payments/payment/incoming/routes.ts index eecbc1db5a..d0c7b710a2 100644 --- a/packages/backend/src/open_payments/payment/incoming/routes.ts +++ b/packages/backend/src/open_payments/payment/incoming/routes.ts @@ -70,7 +70,8 @@ async function getIncomingPaymentPublic( ) { const incomingPayment = await deps.incomingPaymentService.get({ id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined, + tenantId: ctx.params.tenantId }) if (!incomingPayment) { @@ -84,7 +85,7 @@ async function getIncomingPaymentPublic( } ctx.body = incomingPayment.toPublicOpenPaymentsType( - deps.config.authServerGrantUrl + `${deps.config.authServerGrantUrl}/${incomingPayment?.walletAddress?.tenantId}` ) } @@ -94,7 +95,8 @@ async function getIncomingPaymentPrivate( ): Promise { const incomingPayment = await deps.incomingPaymentService.get({ id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined, + tenantId: ctx.params.tenantId }) if (!incomingPayment) { @@ -138,7 +140,8 @@ async function createIncomingPayment( client: ctx.client, metadata: body.metadata, expiresAt, - incomingAmount: body.incomingAmount && parseAmount(body.incomingAmount) + incomingAmount: body.incomingAmount && parseAmount(body.incomingAmount), + tenantId: ctx.params.tenantId }) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -163,7 +166,8 @@ async function completeIncomingPayment( ctx: CompleteContext ): Promise { const incomingPaymentOrError = await deps.incomingPaymentService.complete( - ctx.params.id + ctx.params.id, + ctx.params.tenantId ) if (isIncomingPaymentError(incomingPaymentOrError)) { @@ -182,7 +186,13 @@ async function listIncomingPayments( ): Promise { await listSubresource({ ctx, - getWalletAddressPage: deps.incomingPaymentService.getWalletAddressPage, + getWalletAddressPage: async ({ walletAddressId, pagination, client }) => + deps.incomingPaymentService.getWalletAddressPage({ + walletAddressId, + pagination, + client, + tenantId: ctx.params.tenantId + }), toBody: (payment) => payment.toOpenPaymentsType(ctx.walletAddress) }) } 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 6fa47cef0d..afacfd0452 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.test.ts @@ -38,6 +38,7 @@ describe('Incoming Payment Service', (): void => { let accountingService: AccountingService let asset: Asset let config: IAppConfig + let tenantId: string beforeAll(async (): Promise => { deps = initIocContainer({ @@ -49,6 +50,7 @@ describe('Incoming Payment Service', (): void => { knex = appContainer.knex incomingPaymentService = await deps.use('incomingPaymentService') config = await deps.use('config') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { @@ -104,7 +106,8 @@ describe('Incoming Payment Service', (): void => { const options = { client: faker.internet.url({ appendSlash: false }), incomingAmount: true, - expiresAt: new Date(Date.now() + 30_000) + expiresAt: new Date(Date.now() + 30_000), + tenantId } return incomingPaymentService.create({ @@ -174,9 +177,9 @@ describe('Incoming Payment Service', (): void => { describe('approveIncomingPayment', (): void => { it('should return UnknownPayment error if payment does not exist', async (): Promise => { - expect(incomingPaymentService.approve(uuid())).resolves.toBe( - IncomingPaymentError.UnknownPayment - ) + expect( + incomingPaymentService.approve(uuid(), Config.operatorTenantId) + ).resolves.toBe(IncomingPaymentError.UnknownPayment) }) it('should not approve already cancelled incoming payment', async (): Promise => { @@ -188,7 +191,8 @@ describe('Incoming Payment Service', (): void => { .patch({ cancelledAt: new Date() }) const response = await incomingPaymentService.approve( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) expect(response).toBe(IncomingPaymentError.AlreadyActioned) }) @@ -203,7 +207,8 @@ describe('Incoming Payment Service', (): void => { .patch({ approvedAt }) const approvedPayment = await incomingPaymentService.approve( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(approvedPayment)) @@ -221,7 +226,8 @@ describe('Incoming Payment Service', (): void => { .patch({ state: IncomingPaymentState.Pending }) const approvedIncomingPayment = await incomingPaymentService.approve( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(approvedIncomingPayment)) expect(approvedIncomingPayment.id).toBe(incomingPayment.id) @@ -233,9 +239,9 @@ describe('Incoming Payment Service', (): void => { describe('cancelIncomingPayment', (): void => { it('should return UnknownPayment error if payment does not exist', async (): Promise => { - expect(incomingPaymentService.cancel(uuid())).resolves.toBe( - IncomingPaymentError.UnknownPayment - ) + expect( + incomingPaymentService.cancel(uuid(), Config.operatorTenantId) + ).resolves.toBe(IncomingPaymentError.UnknownPayment) }) it('should not cancel already approved incoming payment', async (): Promise => { @@ -246,7 +252,10 @@ describe('Incoming Payment Service', (): void => { .findOne({ id: incomingPayment.id }) .patch({ approvedAt: new Date() }) - const response = await incomingPaymentService.cancel(incomingPayment.id) + const response = await incomingPaymentService.cancel( + incomingPayment.id, + Config.operatorTenantId + ) expect(response).toBe(IncomingPaymentError.AlreadyActioned) }) @@ -260,7 +269,8 @@ describe('Incoming Payment Service', (): void => { .patch({ cancelledAt }) const cancelledPayment = await incomingPaymentService.cancel( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(cancelledPayment)) @@ -278,7 +288,8 @@ describe('Incoming Payment Service', (): void => { .patch({ state: IncomingPaymentState.Pending }) const canceledIncomingPayment = await incomingPaymentService.cancel( - incomingPayment.id + incomingPayment.id, + Config.operatorTenantId ) assert.ok(!isIncomingPaymentError(canceledIncomingPayment)) expect(canceledIncomingPayment.id).toBe(incomingPayment.id) @@ -343,7 +354,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.UnknownWalletAddress) }) @@ -365,7 +377,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) await expect( @@ -380,7 +393,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) }) @@ -398,7 +412,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) await expect( @@ -413,7 +428,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) }) @@ -431,7 +447,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) @@ -454,7 +471,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InactiveWalletAddress) }) @@ -474,7 +492,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) @@ -491,7 +510,8 @@ describe('Incoming Payment Service', (): void => { } payment = (await incomingPaymentService.create({ walletAddressId, - incomingAmount: amount + incomingAmount: amount, + tenantId })) as IncomingPayment assert.ok(!isIncomingPaymentError(payment)) }) @@ -505,6 +525,7 @@ describe('Incoming Payment Service', (): void => { async ({ metadata }): Promise => { const incomingPayment = await incomingPaymentService.update({ id: payment.id, + tenantId: Config.operatorTenantId, metadata }) assert.ok(!isIncomingPaymentError(incomingPayment)) @@ -519,6 +540,7 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.update({ id: uuid(), + tenantId: Config.operatorTenantId, metadata: { description: 'Test incoming payment', externalRef: '#123' @@ -543,7 +565,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }), get: (options) => incomingPaymentService.get(options), list: (options) => incomingPaymentService.getWalletAddressPage(options) @@ -565,7 +588,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPaymentOrError)) incomingPayment = incomingPaymentOrError @@ -628,7 +652,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPaymentOrError)) const incomingPaymentId = incomingPaymentOrError.id @@ -657,7 +682,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) await expect( accountingService.createDeposit({ @@ -694,7 +720,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) jest.useFakeTimers() jest.setSystemTime(incomingPayment.expiresAt) @@ -731,7 +758,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) await expect( accountingService.createDeposit({ @@ -819,7 +847,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) }) test('updates state of pending incoming payment to complete', async (): Promise => { @@ -827,11 +856,15 @@ describe('Incoming Payment Service', (): void => { jest.useFakeTimers({ now }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, - processAt: now + processAt: now, + tenantId: Config.operatorTenantId }) await expect( incomingPaymentService.get({ @@ -841,12 +874,21 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Completed, processAt: now }) + await expect( + incomingPaymentService.get({ + id: incomingPayment.id, + tenantId: Config.operatorTenantId + }) + ).resolves.toMatchObject({ + state: IncomingPaymentState.Completed, + processAt: now + }) }) test('fails to complete unknown payment', async (): Promise => { - await expect(incomingPaymentService.complete(uuid())).resolves.toEqual( - IncomingPaymentError.UnknownPayment - ) + await expect( + incomingPaymentService.complete(uuid(), Config.operatorTenantId) + ).resolves.toEqual(IncomingPaymentError.UnknownPayment) }) test('updates state of processing incoming payment to complete', async (): Promise => { @@ -864,7 +906,10 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Processing }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toMatchObject({ id: incomingPayment.id, state: IncomingPaymentState.Completed, @@ -902,7 +947,10 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Expired }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toBe(IncomingPaymentError.WrongState) await expect( incomingPaymentService.get({ @@ -925,7 +973,10 @@ describe('Incoming Payment Service', (): void => { state: IncomingPaymentState.Completed }) await expect( - incomingPaymentService.complete(incomingPayment.id) + incomingPaymentService.complete( + incomingPayment.id, + Config.operatorTenantId + ) ).resolves.toBe(IncomingPaymentError.WrongState) await expect( incomingPaymentService.get({ diff --git a/packages/backend/src/open_payments/payment/incoming/service.ts b/packages/backend/src/open_payments/payment/incoming/service.ts index 6229c613fe..371eaafb41 100644 --- a/packages/backend/src/open_payments/payment/incoming/service.ts +++ b/packages/backend/src/open_payments/payment/incoming/service.ts @@ -32,11 +32,13 @@ export interface CreateIncomingPaymentOptions { expiresAt?: Date incomingAmount?: Amount metadata?: Record + tenantId: string } export interface UpdateOptions { id: string metadata: Record + tenantId: string } export interface IncomingPaymentService @@ -45,9 +47,18 @@ export interface IncomingPaymentService options: CreateIncomingPaymentOptions, trx?: Knex.Transaction ): Promise - approve(id: string): Promise - cancel(id: string): Promise - complete(id: string): Promise + approve( + id: string, + tenantId: string + ): Promise + cancel( + id: string, + tenantId: string + ): Promise + complete( + id: string, + tenantId: string + ): Promise processNext(): Promise update( options: UpdateOptions @@ -75,9 +86,9 @@ export async function createIncomingPaymentService( return { get: (options) => getIncomingPayment(deps, options), create: (options, trx) => createIncomingPayment(deps, options, trx), - approve: (id) => approveIncomingPayment(deps, id), - cancel: (id) => cancelIncomingPayment(deps, id), - complete: (id) => completeIncomingPayment(deps, id), + approve: (id, tenantId) => approveIncomingPayment(deps, id, tenantId), + cancel: (id, tenantId) => cancelIncomingPayment(deps, id, tenantId), + complete: (id, tenantId) => completeIncomingPayment(deps, id, tenantId), getWalletAddressPage: (options) => getWalletAddressPage(deps, options), processNext: () => processNextIncomingPayment(deps), update: (options) => updateIncomingPayment(deps, options) @@ -107,7 +118,7 @@ async function updateIncomingPayment( ): Promise { const incomingPayment = await IncomingPayment.query( deps.knex - ).patchAndFetchById(options.id, { metadata: options.metadata }) + ).patchAndFetchById(options.id, options) if (incomingPayment) { const asset = await deps.assetService.get(incomingPayment.assetId) if (asset) incomingPayment.asset = asset @@ -129,7 +140,8 @@ async function createIncomingPayment( client, expiresAt, incomingAmount, - metadata + metadata, + tenantId }: CreateIncomingPaymentOptions, trx?: Knex.Transaction ): Promise { @@ -144,7 +156,10 @@ async function createIncomingPayment( if (incomingAmount && incomingAmount.value <= 0) { return IncomingPaymentError.InvalidAmount } - const walletAddress = await deps.walletAddressService.get(walletAddressId) + const walletAddress = await deps.walletAddressService.get( + walletAddressId, + tenantId + ) if (!walletAddress) { return IncomingPaymentError.UnknownWalletAddress } @@ -170,7 +185,8 @@ async function createIncomingPayment( incomingAmount, metadata, state: IncomingPaymentState.Pending, - processAt: expiresAt + processAt: expiresAt, + tenantId }) const asset = await deps.assetService.get(incomingPayment.assetId) @@ -363,7 +379,12 @@ async function getWalletAddressPage( deps: ServiceDependencies, options: ListOptions ): Promise { - const page = await IncomingPayment.query(deps.knex).list(options) + const pageQuery = IncomingPayment.query(deps.knex) + + if (options.tenantId) pageQuery.where('tenantId', options.tenantId) + + const page = await pageQuery.list(options) + for (const payment of page) { const asset = await deps.assetService.get(payment.assetId) if (asset) payment.asset = asset @@ -399,10 +420,13 @@ async function getWalletAddressPage( async function approveIncomingPayment( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { return deps.knex.transaction(async (trx) => { - const payment = await IncomingPayment.query(trx).findById(id).forUpdate() + const payment = await IncomingPayment.query(trx) + .findOne({ id, tenantId }) + .forUpdate() if (!payment) return IncomingPaymentError.UnknownPayment @@ -436,10 +460,13 @@ async function approveIncomingPayment( async function cancelIncomingPayment( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { return deps.knex.transaction(async (trx) => { - const payment = await IncomingPayment.query(trx).findById(id).forUpdate() + const payment = await IncomingPayment.query(trx) + .findOne({ id, tenantId }) + .forUpdate() if (!payment) return IncomingPaymentError.UnknownPayment @@ -473,10 +500,13 @@ async function cancelIncomingPayment( async function completeIncomingPayment( deps: ServiceDependencies, - id: string + id: string, + tenantId: string ): Promise { return deps.knex.transaction(async (trx) => { - const payment = await IncomingPayment.query(trx).findById(id).forUpdate() + const payment = await IncomingPayment.query(trx) + .findOne({ id, tenantId }) + .forUpdate() if (!payment) return IncomingPaymentError.UnknownPayment const asset = await deps.assetService.get(payment.assetId) 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 42e3771e47..72c830e5c5 100644 --- a/packages/backend/src/open_payments/payment/incoming_remote/service.ts +++ b/packages/backend/src/open_payments/payment/incoming_remote/service.ts @@ -13,7 +13,6 @@ 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 @@ -103,7 +102,7 @@ async function createIncomingPayment( walletAddress.resourceServer ?? new URL(walletAddress.id).origin const grantOptions = { - authServer: urlWithoutTenantId(walletAddress.authServer), + authServer: walletAddress.authServer, accessType: AccessType.IncomingPayment, accessActions: [AccessAction.Create, AccessAction.ReadAll] } @@ -117,7 +116,7 @@ async function createIncomingPayment( try { return await deps.openPaymentsClient.incomingPayment.create( { - url: urlWithoutTenantId(resourceServerUrl), + url: resourceServerUrl, accessToken: grant.accessToken }, { @@ -217,7 +216,7 @@ async function getIncomingPayment( OpenPaymentsIncomingPaymentWithPaymentMethods | RemoteIncomingPaymentError > { const grantOptions = { - authServer: urlWithoutTenantId(authServerUrl), + authServer: authServerUrl, accessType: AccessType.IncomingPayment, accessActions: [AccessAction.ReadAll] } diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 130e9391b6..302d8ba72e 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -15,6 +15,7 @@ import { OutgoingPayment as OpenPaymentsOutgoingPayment, OutgoingPaymentWithSpentAmounts } from '@interledger/open-payments' +import { Tenant } from '../../../tenants/model' export class OutgoingPaymentGrant extends DbErrors(Model) { public static get modelPaths(): string[] { @@ -108,7 +109,7 @@ export class OutgoingPayment public getUrl(walletAddress: WalletAddress): string { const url = new URL(walletAddress.url) - return `${url.origin}${OutgoingPayment.urlPath}/${this.id}` + return `${url.origin}/${this.tenantId}${OutgoingPayment.urlPath}/${this.id}` } public get asset(): Asset { @@ -125,6 +126,8 @@ export class OutgoingPayment // Outgoing peer public peerId?: string + public tenantId!: string + static get relationMappings() { return { ...super.relationMappings, @@ -135,6 +138,14 @@ export class OutgoingPayment from: 'outgoingPayments.id', to: 'quotes.id' } + }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant, + join: { + from: 'outgoingPayments.tenantId', + to: 'tenants.id' + } } } } 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 270586fc22..98a744f1e3 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.test.ts @@ -41,6 +41,7 @@ describe('Outgoing Payment Routes', (): void => { let outgoingPaymentService: OutgoingPaymentService let walletAddress: WalletAddress let baseUrl: string + let tenantId: string const receivingWalletAddress = `https://wallet.example/${uuid()}` @@ -51,6 +52,7 @@ describe('Outgoing Payment Routes', (): void => { }): Promise => { return await createOutgoingPayment(deps, { ...options, + tenantId: Config.operatorTenantId, walletAddressId: walletAddress.id, method: 'ilp', receiver: `${receivingWalletAddress}/incoming-payments/${uuid()}`, @@ -77,8 +79,9 @@ describe('Outgoing Payment Routes', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) + tenantId = Config.operatorTenantId walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) baseUrl = new URL(walletAddress.url).origin @@ -117,7 +120,7 @@ describe('Outgoing Payment Routes', (): void => { get: (ctx) => outgoingPaymentRoutes.get(ctx), getBody: (outgoingPayment) => { return { - id: `${baseUrl}/outgoing-payments/${outgoingPayment.id}`, + id: `${baseUrl}/${tenantId}/outgoing-payments/${outgoingPayment.id}`, walletAddress: walletAddress.url, receiver: outgoingPayment.receiver, quoteId: outgoingPayment.quote.getUrl(walletAddress), @@ -137,7 +140,7 @@ describe('Outgoing Payment Routes', (): void => { type SetupContextOptions = UnionOmit< CreateOutgoingPaymentOptions, - 'walletAddressId' + 'walletAddressId' | 'tenantId' > describe('create', (): void => { @@ -152,6 +155,9 @@ describe('Outgoing Payment Routes', (): void => { url: `/outgoing-payments`, body: options }, + params: { + tenantId + }, walletAddress, client: options.client, grant: options.grant @@ -185,6 +191,7 @@ describe('Outgoing Payment Routes', (): void => { CreateOutgoingPaymentBaseOptions, 'walletAddressId' > = { + tenantId, client, grant, metadata @@ -192,7 +199,7 @@ describe('Outgoing Payment Routes', (): void => { if (createFrom === CreateFrom.Quote) { options = { ...options, - quoteId: `${baseUrl}/quotes/${payment.quote.id}` + quoteId: `${baseUrl}/${payment.quote.tenantId}/quotes/${payment.quote.id}` } as CreateFromQuote } else { assert(createFrom === CreateFrom.IncomingPayment) @@ -215,6 +222,7 @@ describe('Outgoing Payment Routes', (): void => { ).resolves.toBeUndefined() let expectedCreateOptions: CreateOutgoingPaymentBaseOptions = { + tenantId, walletAddressId: walletAddress.id, metadata, client, @@ -243,7 +251,7 @@ describe('Outgoing Payment Routes', (): void => { .split('/') .pop() expect(ctx.response.body).toEqual({ - id: `${baseUrl}/outgoing-payments/${outgoingPaymentId}`, + id: `${baseUrl}/${tenantId}/outgoing-payments/${outgoingPaymentId}`, walletAddress: walletAddress.url, receiver: payment.receiver, quoteId: @@ -284,8 +292,9 @@ describe('Outgoing Payment Routes', (): void => { 'returns error on %s', async (error): Promise => { const quoteId = uuid() + const tenantId = Config.operatorTenantId const ctx = setup({ - quoteId: `${baseUrl}/quotes/${quoteId}` + quoteId: `${baseUrl}/${tenantId}/quotes/${quoteId}` }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -303,7 +312,8 @@ describe('Outgoing Payment Routes', (): void => { expect(createSpy).toHaveBeenCalledWith({ walletAddressId: walletAddress.id, - quoteId + quoteId, + tenantId }) } ) diff --git a/packages/backend/src/open_payments/payment/outgoing/routes.ts b/packages/backend/src/open_payments/payment/outgoing/routes.ts index e39e0c3baf..5ba4644fa7 100644 --- a/packages/backend/src/open_payments/payment/outgoing/routes.ts +++ b/packages/backend/src/open_payments/payment/outgoing/routes.ts @@ -54,6 +54,7 @@ async function getOutgoingPayment( ): Promise { const outgoingPayment = await deps.outgoingPaymentService.get({ id: ctx.params.id, + tenantId: ctx.params.tenantId, client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined }) @@ -98,6 +99,7 @@ async function createOutgoingPayment( ): Promise { const { body } = ctx.request const baseOptions: OutgoingPaymentCreateBaseOptions = { + tenantId: ctx.params.tenantId, walletAddressId: ctx.walletAddress.id, metadata: body.metadata, client: ctx.client, @@ -148,7 +150,13 @@ async function listOutgoingPayments( ): Promise { await listSubresource({ ctx, - getWalletAddressPage: deps.outgoingPaymentService.getWalletAddressPage, + getWalletAddressPage: async ({ walletAddressId, pagination, client }) => + deps.outgoingPaymentService.getWalletAddressPage({ + walletAddressId, + pagination, + client, + tenantId: ctx.params.tenantId + }), toBody: (payment) => outgoingPaymentToBody(ctx.walletAddress, payment) }) } 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 cb60860df1..05007973d3 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -54,6 +54,7 @@ import { TelemetryService } from '../../../telemetry/service' import { getPageTests } from '../../../shared/baseModel.test' import { Pagination, SortOrder } from '../../../shared/baseModel' import { ReceiverService } from '../../receiver/service' +import { WalletAddressService } from '../../wallet_address/service' describe('OutgoingPaymentService', (): void => { let deps: IocContract @@ -62,9 +63,11 @@ describe('OutgoingPaymentService', (): void => { let accountingService: AccountingService let paymentMethodHandlerService: PaymentMethodHandlerService let quoteService: QuoteService + let walletAddressService: WalletAddressService let telemetryService: TelemetryService let knex: Knex let assetId: string + let tenantId: string let walletAddressId: string let incomingPayment: IncomingPayment let receiverWalletAddress: MockWalletAddress @@ -262,6 +265,7 @@ describe('OutgoingPaymentService', (): void => { accountingService = await deps.use('accountingService') paymentMethodHandlerService = await deps.use('paymentMethodHandlerService') quoteService = await deps.use('quoteService') + walletAddressService = await deps.use('walletAddressService') telemetryService = (await deps.use('telemetry'))! config = await deps.use('config') knex = appContainer.knex @@ -269,17 +273,18 @@ describe('OutgoingPaymentService', (): void => { }) beforeEach(async (): Promise => { + tenantId = config.operatorTenantId const { id: sendAssetId } = await createAsset(deps, asset) assetId = sendAssetId const walletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: sendAssetId }) walletAddressId = walletAddress.id client = walletAddress.url const { id: destinationAssetId } = await createAsset(deps, destinationAsset) receiverWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -292,7 +297,8 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toBeUndefined() incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receiverWalletAddress.id + walletAddressId: receiverWalletAddress.id, + tenantId: Config.operatorTenantId }) receiver = incomingPayment.getUrl(receiverWalletAddress) @@ -329,6 +335,7 @@ describe('OutgoingPaymentService', (): void => { getTests({ createModel: ({ client }) => createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -344,6 +351,7 @@ describe('OutgoingPaymentService', (): void => { describe('get', (): void => { test('throws error if cannot find liquidity account for SENDING payment', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -351,6 +359,7 @@ describe('OutgoingPaymentService', (): void => { }) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id, client @@ -365,6 +374,7 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toEqual(payment) await expect( outgoingPaymentService.fund({ + tenantId, id: payment.id, amount: payment.debitAmount.value, transferId: uuid() @@ -388,6 +398,7 @@ describe('OutgoingPaymentService', (): void => { getPageTests({ createModel: () => createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -411,19 +422,21 @@ describe('OutgoingPaymentService', (): void => { let otherOutgoingPayment: OutgoingPayment beforeEach(async (): Promise => { otherSenderWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId }) otherReceiverWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receiverWalletAddress.id + walletAddressId: receiverWalletAddress.id, + tenantId: Config.operatorTenantId }) otherReceiver = incomingPayment.getUrl(otherReceiverWalletAddress) outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -437,6 +450,7 @@ describe('OutgoingPaymentService', (): void => { }) otherOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: otherSenderWalletAddress.id, client, receiver: otherReceiver, @@ -510,6 +524,7 @@ describe('OutgoingPaymentService', (): void => { describe('getWalletAddressPage', (): void => { test('throws error if cannot find liquidity account for SENDING payment', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -517,6 +532,7 @@ describe('OutgoingPaymentService', (): void => { }) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, client, quoteId: quote.id @@ -532,6 +548,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: payment.debitAmount.value, transferId: uuid() }) @@ -583,6 +600,7 @@ describe('OutgoingPaymentService', (): void => { * 4. Based on state, check the result */ const outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -599,6 +617,7 @@ describe('OutgoingPaymentService', (): void => { const response = await outgoingPaymentService.cancel({ id: outgoingPayment.id, + tenantId, reason }) @@ -637,6 +656,7 @@ describe('OutgoingPaymentService', (): void => { const quoteSpy = jest.spyOn(quoteService, 'create') const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, debitAmount, incomingPayment: incomingPaymentUrl @@ -644,6 +664,7 @@ describe('OutgoingPaymentService', (): void => { expect(!isOutgoingPaymentError(payment)).toBeTruthy() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId, receiver: incomingPaymentUrl, debitAmount, @@ -677,6 +698,7 @@ describe('OutgoingPaymentService', (): void => { }) const options: CreateOutgoingPaymentOptions = { + tenantId, walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( @@ -722,6 +744,7 @@ describe('OutgoingPaymentService', (): void => { }) const options: CreateOutgoingPaymentOptions = { + tenantId, walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( @@ -759,6 +782,7 @@ describe('OutgoingPaymentService', (): void => { .mockImplementationOnce(async () => quoteCreateResponse) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, debitAmount, incomingPayment: incomingPaymentUrl @@ -767,6 +791,7 @@ describe('OutgoingPaymentService', (): void => { expect(isOutgoingPaymentError(payment)).toBeTruthy() expect(payment).toBe(quoteCreateResponse) expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId, receiver: incomingPaymentUrl, debitAmount, @@ -808,12 +833,14 @@ describe('OutgoingPaymentService', (): void => { const peerService = await deps.use('peerService') const peer = await createPeer(deps) const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, method: 'ilp' }) const options = { + tenantId, walletAddressId, client, quoteId: quote.id, @@ -871,23 +898,59 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on unknown wallet address', async () => { const { id: quoteId } = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, validDestination: false, method: 'ilp' }) + const unknownWalletAddressId = uuid() + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) await expect( outgoingPaymentService.create({ - walletAddressId: uuid(), + tenantId, + walletAddressId: unknownWalletAddressId, quoteId }) ).resolves.toEqual(OutgoingPaymentError.UnknownWalletAddress) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + unknownWalletAddressId, + tenantId + ) + }) + + it('fails to create on unknown tenant id', async () => { + const { id: quoteId } = await createQuote(deps, { + tenantId, + walletAddressId, + receiver, + debitAmount, + validDestination: false, + method: 'ilp' + }) + + const unknownTenandId = uuid() + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) + await expect( + outgoingPaymentService.create({ + tenantId: unknownTenandId, + walletAddressId, + quoteId + }) + ).resolves.toEqual(OutgoingPaymentError.UnknownWalletAddress) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + walletAddressId, + unknownTenandId + ) }) it('fails to create on unknown quote', async () => { await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: uuid() }) @@ -896,6 +959,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on "consumed" quote', async () => { const { quote } = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -904,6 +968,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, client, quoteId: quote.id @@ -913,6 +978,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on invalid quote wallet address', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -921,6 +987,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId: receiverWalletAddress.id, quoteId: quote.id }) @@ -929,6 +996,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on expired quote', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -940,6 +1008,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id }) @@ -953,6 +1022,7 @@ describe('OutgoingPaymentService', (): void => { `fails to create on $state quote receiver`, async ({ state }): Promise => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -965,6 +1035,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id }) @@ -974,6 +1045,7 @@ describe('OutgoingPaymentService', (): void => { test('fails to create on inactive wallet address', async () => { const { id: quoteId } = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -981,7 +1053,7 @@ describe('OutgoingPaymentService', (): void => { method: 'ilp' }) const walletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId + tenantId }) const walletAddressUpdated = await WalletAddress.query( knex @@ -989,6 +1061,7 @@ describe('OutgoingPaymentService', (): void => { assert.ok(!walletAddressUpdated.isActive) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId: walletAddress.id, quoteId }) @@ -1005,6 +1078,7 @@ describe('OutgoingPaymentService', (): void => { const quotes = await Promise.all( [0, 1].map(async (_) => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1014,6 +1088,7 @@ describe('OutgoingPaymentService', (): void => { ) const options = quotes.map((quote) => { return { + tenantId, walletAddressId, client, quoteId: quote.id, @@ -1056,12 +1131,14 @@ describe('OutgoingPaymentService', (): void => { let interval: string beforeEach(async (): Promise => { quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, method: 'ilp' }) options = { + tenantId, walletAddressId, quoteId: quote.id, metadata: { @@ -1091,6 +1168,7 @@ describe('OutgoingPaymentService', (): void => { receiver } const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1191,6 +1269,7 @@ describe('OutgoingPaymentService', (): void => { value: BigInt(190) } const firstPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver: `${ @@ -1278,6 +1357,7 @@ describe('OutgoingPaymentService', (): void => { value: BigInt(7) } const firstPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver: `${ @@ -1334,6 +1414,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: payment.debitAmount.value, transferId: uuid() }) @@ -1355,11 +1436,13 @@ describe('OutgoingPaymentService', (): void => { value: receiveAmount.value, assetCode: receiverWalletAddress.asset.code, assetScale: receiverWalletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) assert.ok(incomingPayment.walletAddress) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(incomingPayment.walletAddress), receiveAmount, method: 'ilp' @@ -1392,6 +1475,7 @@ describe('OutgoingPaymentService', (): void => { .spyOn(telemetryService!, 'incrementCounter') .mockImplementation(() => Promise.resolve()) const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1436,11 +1520,13 @@ describe('OutgoingPaymentService', (): void => { value: receiveAmount.value * 2n, assetCode: receiverWalletAddress.asset.code, assetScale: receiverWalletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) assert.ok(incomingPayment.walletAddress) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(incomingPayment.walletAddress), receiveAmount, method: 'ilp' @@ -1476,6 +1562,7 @@ describe('OutgoingPaymentService', (): void => { const spyCounter = jest.spyOn(telemetryService, 'incrementCounter') const createdPayment = await setup({ + tenantId, receiver, debitAmount, receiveAmount, @@ -1506,7 +1593,8 @@ describe('OutgoingPaymentService', (): void => { value: receiveAmount.value * 2n, assetCode: receiverWalletAddress.asset.code, assetScale: receiverWalletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) assert.ok(incomingPayment.id) assert.ok(incomingPayment.createdAt) @@ -1529,6 +1617,7 @@ describe('OutgoingPaymentService', (): void => { assert.ok(incomingPayment.receivedAmount?.assetScale) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(incomingPayment.walletAddress), receiveAmount, method: 'ilp' @@ -1551,6 +1640,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (with incoming payment initially partially paid)', async (): Promise => { const createdPayment = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1588,6 +1678,7 @@ describe('OutgoingPaymentService', (): void => { test('SENDING -> FAILED (partial payment then retryable Pay error)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1638,6 +1729,7 @@ describe('OutgoingPaymentService', (): void => { test('FAILED (non-retryable error)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1669,6 +1761,7 @@ describe('OutgoingPaymentService', (): void => { test('SENDING→COMPLETED (partial payment, resume, complete)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1700,6 +1793,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (already fully paid)', async (): Promise => { const createdPayment = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1731,6 +1825,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (already fully paid)', async (): Promise => { const { id: paymentId } = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1756,6 +1851,7 @@ describe('OutgoingPaymentService', (): void => { test('FAILED (source asset changed)', async (): Promise => { const { id: paymentId } = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1780,6 +1876,7 @@ describe('OutgoingPaymentService', (): void => { }) test('FAILED (destination asset changed)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1808,6 +1905,7 @@ describe('OutgoingPaymentService', (): void => { beforeEach(async (): Promise => { payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1826,6 +1924,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: uuid(), + tenantId, amount: quoteAmount, transferId: uuid() }) @@ -1836,6 +1935,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount, transferId: uuid() }) @@ -1855,6 +1955,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount - BigInt(1), transferId: uuid() }) @@ -1874,6 +1975,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount, transferId: uuid() }) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 54ede1accb..8e16eb3b74 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -101,6 +101,7 @@ interface GetPageOptions { pagination?: Pagination filter?: OutgoingPaymentFilter sortOrder?: SortOrder + tenantId?: string } async function getOutgoingPaymentsPage( @@ -153,11 +154,17 @@ async function getOutgoingPayment( options: GetOptions ): Promise { const outgoingPayment = await OutgoingPayment.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) .get(options) .withGraphFetched('quote') if (outgoingPayment) { outgoingPayment.walletAddress = await deps.walletAddressService.get( - outgoingPayment.walletAddressId + outgoingPayment.walletAddressId, + outgoingPayment.tenantId ) const asset = await deps.assetService.get(outgoingPayment.quote.assetId) if (asset) outgoingPayment.quote.asset = asset @@ -167,6 +174,7 @@ async function getOutgoingPayment( } export interface BaseOptions { + tenantId: string walletAddressId: string client?: string grant?: Grant @@ -184,6 +192,7 @@ export interface CreateFromIncomingPayment extends BaseOptions { export type CancelOutgoingPaymentOptions = { id: string + tenantId: string reason?: string } @@ -201,10 +210,15 @@ async function cancelOutgoingPayment( deps: ServiceDependencies, options: CancelOutgoingPaymentOptions ): Promise { - const { id } = options + const { id, tenantId } = options return deps.knex.transaction(async (trx) => { - let payment = await OutgoingPayment.query(trx).findById(id).forUpdate() + let payment = await OutgoingPayment.query(trx) + .findOne({ + id, + tenantId + }) + .forUpdate() if (!payment) return OutgoingPaymentError.UnknownPayment if (payment.state !== OutgoingPaymentState.Funding) { @@ -243,7 +257,7 @@ async function createOutgoingPayment( description: 'Time to create an outgoing payment' } ) - const { walletAddressId } = options + const { walletAddressId, tenantId } = options let quoteId: string if (isCreateFromIncomingPayment(options)) { @@ -256,6 +270,7 @@ async function createOutgoingPayment( ) const { debitAmount, incomingPayment } = options const quoteOrError = await deps.quoteService.create({ + tenantId, receiver: incomingPayment, debitAmount, method: 'ilp', @@ -281,7 +296,10 @@ async function createOutgoingPayment( description: 'Time to get wallet address in outgoing payment' } ) - const walletAddress = await deps.walletAddressService.get(walletAddressId) + const walletAddress = await deps.walletAddressService.get( + walletAddressId, + tenantId + ) stopTimerWA() if (!walletAddress) { throw OutgoingPaymentError.UnknownWalletAddress @@ -316,6 +334,7 @@ async function createOutgoingPayment( const payment = await OutgoingPayment.query(trx) .insertAndFetch({ id: quoteId, + tenantId, walletAddressId: walletAddressId, client: options.client, metadata: options.metadata, @@ -621,17 +640,21 @@ async function validateGrantAndAddSpentAmountsToPayment( export interface FundOutgoingPaymentOptions { id: string + tenantId: string amount: bigint transferId: string } async function fundPayment( deps: ServiceDependencies, - { id, amount, transferId }: FundOutgoingPaymentOptions + { id, tenantId, amount, transferId }: FundOutgoingPaymentOptions ): Promise { return await deps.knex.transaction(async (trx) => { const payment = await OutgoingPayment.query(trx) - .findById(id) + .findOne({ + id, + tenantId + }) .forUpdate() .withGraphFetched('quote') if (!payment) return FundingError.UnknownPayment @@ -680,11 +703,17 @@ async function getWalletAddressPage( options: ListOptions ): Promise { const page = await OutgoingPayment.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) .list(options) .withGraphFetched('quote') for (const payment of page) { payment.walletAddress = await deps.walletAddressService.get( - payment.walletAddressId + payment.walletAddressId, + payment.tenantId ) const asset = await deps.assetService.get(payment.quote.assetId) if (asset) payment.quote.asset = asset diff --git a/packages/backend/src/open_payments/quote/model.ts b/packages/backend/src/open_payments/quote/model.ts index 3c6bd6d135..a05352e228 100644 --- a/packages/backend/src/open_payments/quote/model.ts +++ b/packages/backend/src/open_payments/quote/model.ts @@ -7,6 +7,7 @@ import { import { Asset } from '../../asset/model' import { Quote as OpenPaymentsQuote } from '@interledger/open-payments' import { Fee } from '../../fee/model' +import { Tenant } from '../../tenants/model' export class Quote extends WalletAddressSubresource { public static readonly tableName = 'quotes' @@ -26,6 +27,8 @@ export class Quote extends WalletAddressSubresource { public debitAmountMinusFees?: bigint + public tenantId!: string + static get relationMappings() { return { ...super.relationMappings, @@ -44,6 +47,14 @@ export class Quote extends WalletAddressSubresource { from: 'quotes.feeId', to: 'fees.id' } + }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant, + join: { + from: 'quotes.tenantId', + to: 'tenants.id' + } } } } @@ -56,7 +67,7 @@ export class Quote extends WalletAddressSubresource { public getUrl(walletAddress: WalletAddress): string { const url = new URL(walletAddress.url) - return `${url.origin}${Quote.urlPath}/${this.id}` + return `${url.origin}/${this.tenantId}${Quote.urlPath}/${this.id}` } public get debitAmount(): Amount { diff --git a/packages/backend/src/open_payments/quote/routes.test.ts b/packages/backend/src/open_payments/quote/routes.test.ts index 791b4f48b3..3c161305fb 100644 --- a/packages/backend/src/open_payments/quote/routes.test.ts +++ b/packages/backend/src/open_payments/quote/routes.test.ts @@ -30,6 +30,7 @@ describe('Quote Routes', (): void => { let quoteRoutes: QuoteRoutes let walletAddress: WalletAddress let baseUrl: string + let tenantId: string const receiver = `https://wallet2.example/incoming-payments/${uuid()}` const asset = randomAsset() @@ -40,13 +41,16 @@ describe('Quote Routes', (): void => { } const createWalletAddressQuote = async ({ + tenantId, walletAddressId, client }: { + tenantId: string walletAddressId: string client?: string }): Promise => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount: { @@ -72,12 +76,13 @@ describe('Quote Routes', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId const { id: assetId } = await createAsset(deps, { code: debitAmount.assetCode, scale: debitAmount.assetScale }) walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId }) baseUrl = new URL(walletAddress.url).origin @@ -96,13 +101,14 @@ describe('Quote Routes', (): void => { getWalletAddress: async () => walletAddress, createModel: async ({ client }) => createWalletAddressQuote({ + tenantId, walletAddressId: walletAddress.id, client }), get: (ctx) => quoteRoutes.get(ctx), getBody: (quote) => { return { - id: `${baseUrl}/quotes/${quote.id}`, + id: `${baseUrl}/${quote.tenantId}/quotes/${quote.id}`, walletAddress: walletAddress.url, receiver: quote.receiver, debitAmount: serializeAmount(quote.debitAmount), @@ -130,6 +136,9 @@ describe('Quote Routes', (): void => { method: 'POST', url: `/quotes` }, + params: { + tenantId + }, walletAddress, client }) @@ -195,6 +204,7 @@ describe('Quote Routes', (): void => { }) await expect(quoteRoutes.create(ctx)).resolves.toBeUndefined() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId: walletAddress.id, receiver, debitAmount: options.debitAmount && { @@ -216,7 +226,7 @@ describe('Quote Routes', (): void => { .pop() assert.ok(quote) expect(ctx.response.body).toEqual({ - id: `${baseUrl}/quotes/${quoteId}`, + id: `${baseUrl}/${tenantId}/quotes/${quoteId}`, walletAddress: walletAddress.url, receiver: quote.receiver, debitAmount: { @@ -254,6 +264,7 @@ describe('Quote Routes', (): void => { }) await expect(quoteRoutes.create(ctx)).resolves.toBeUndefined() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId: walletAddress.id, receiver, client, @@ -267,7 +278,7 @@ describe('Quote Routes', (): void => { .pop() assert.ok(quote) expect(ctx.response.body).toEqual({ - id: `${baseUrl}/quotes/${quoteId}`, + id: `${baseUrl}/${tenantId}/quotes/${quoteId}`, walletAddress: walletAddress.url, receiver: options.receiver, debitAmount: { diff --git a/packages/backend/src/open_payments/quote/routes.ts b/packages/backend/src/open_payments/quote/routes.ts index d0069280fc..86212743c8 100644 --- a/packages/backend/src/open_payments/quote/routes.ts +++ b/packages/backend/src/open_payments/quote/routes.ts @@ -38,7 +38,8 @@ async function getQuote( ): Promise { const quote = await deps.quoteService.get({ id: ctx.params.id, - client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined + client: ctx.accessAction === AccessAction.Read ? ctx.client : undefined, + tenantId: ctx.params.tenantId }) if (!quote) { @@ -73,7 +74,9 @@ async function createQuote( ctx: CreateContext ): Promise { const { body } = ctx.request + const { tenantId } = ctx.params const options: CreateQuoteOptions = { + tenantId, walletAddressId: ctx.walletAddress.id, receiver: body.receiver, client: ctx.client, diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 75e59ec365..ef91ab9568 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -35,12 +35,14 @@ import { PaymentMethodHandlerErrorCode } from '../../payment-method/handler/errors' import { Receiver } from '../receiver/model' +import { WalletAddressService } from '../wallet_address/service' describe('QuoteService', (): void => { let deps: IocContract let appContainer: TestContainer let quoteService: QuoteService let paymentMethodHandlerService: PaymentMethodHandlerService + let walletAddressService: WalletAddressService let receiverService: ReceiverService let knex: Knex let sendingWalletAddress: MockWalletAddress @@ -53,6 +55,7 @@ describe('QuoteService', (): void => { // eslint-disable-next-line @typescript-eslint/no-explicit-any any > + let tenantId: string const asset: AssetOptions = { scale: 9, @@ -87,21 +90,23 @@ describe('QuoteService', (): void => { config = await deps.use('config') quoteService = await deps.use('quoteService') paymentMethodHandlerService = await deps.use('paymentMethodHandlerService') + walletAddressService = await deps.use('walletAddressService') receiverService = await deps.use('receiverService') }) beforeEach(async (): Promise => { + tenantId = config.operatorTenantId const { id: sendAssetId } = await createAsset(deps, { code: debitAmount.assetCode, scale: debitAmount.assetScale }) sendingWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: sendAssetId }) const { id: destinationAssetId } = await createAsset(deps, destinationAsset) receivingWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -137,6 +142,7 @@ describe('QuoteService', (): void => { getTests({ createModel: ({ client }) => createQuote(deps, { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, debitAmount: { @@ -178,9 +184,11 @@ describe('QuoteService', (): void => { beforeEach(async (): Promise => { incomingPayment = await createIncomingPayment(deps, { walletAddressId: receivingWalletAddress.id, - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) options = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(receivingWalletAddress), method: 'ilp' @@ -256,6 +264,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) @@ -342,6 +351,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) @@ -382,9 +392,11 @@ describe('QuoteService', (): void => { const incomingPayment = await createIncomingPayment(deps, { walletAddressId: receivingWalletAddress.id, incomingAmount, - expiresAt: expiryDate + expiresAt: expiryDate, + tenantId: Config.operatorTenantId }) const options: CreateQuoteOptions = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(receivingWalletAddress), receiveAmount, @@ -423,21 +435,52 @@ describe('QuoteService', (): void => { }) } ) + test('fails on unknown tenant id', async (): Promise => { + const walletAddress = await createWalletAddress(deps, { + tenantId + }) + const unknownTenantId = uuid() + + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) + await expect( + quoteService.create({ + tenantId: unknownTenantId, + walletAddressId: walletAddress.id, + receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, + debitAmount, + method: 'ilp' + }) + ).resolves.toEqual(QuoteError.UnknownWalletAddress) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + walletAddress.id, + unknownTenantId + ) + }) test('fails on unknown wallet address', async (): Promise => { + const unknownWalletAddressId = uuid() + jest.spyOn(walletAddressService, 'get').mockResolvedValueOnce(undefined) + await expect( quoteService.create({ - walletAddressId: uuid(), + tenantId, + walletAddressId: unknownWalletAddressId, receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, debitAmount, method: 'ilp' }) ).resolves.toEqual(QuoteError.UnknownWalletAddress) + expect(walletAddressService.get).toHaveBeenCalledTimes(1) + expect(walletAddressService.get).toHaveBeenCalledWith( + unknownWalletAddressId, + tenantId + ) }) test('fails on inactive wallet address', async () => { const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId + tenantId }) const walletAddressUpdated = await WalletAddress.query( knex @@ -445,6 +488,7 @@ describe('QuoteService', (): void => { assert.ok(!walletAddressUpdated.isActive) await expect( quoteService.create({ + tenantId, walletAddressId: walletAddress.id, receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, debitAmount, @@ -456,6 +500,7 @@ describe('QuoteService', (): void => { test('fails on invalid receiver', async (): Promise => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, debitAmount, @@ -478,6 +523,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp', @@ -503,9 +549,11 @@ describe('QuoteService', (): void => { 'fails to create $description', async ({ debitAmount, receiveAmount }): Promise => { const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: receivingWalletAddress.id + walletAddressId: receivingWalletAddress.id, + tenantId: Config.operatorTenantId }) const options: CreateQuoteOptions = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(receivingWalletAddress), method: 'ilp' @@ -529,11 +577,11 @@ describe('QuoteService', (): void => { scale: 2 }) sendingWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: asset.id }) receivingWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: asset.id }) }) @@ -557,7 +605,8 @@ describe('QuoteService', (): void => { assetCode: asset.code, assetScale: asset.scale, value: incomingAmountValue - } + }, + tenantId: Config.operatorTenantId }) await Fee.query().insertAndFetch({ @@ -578,6 +627,7 @@ describe('QuoteService', (): void => { .mockResolvedValueOnce(mockedQuote) const quote = await quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp' @@ -599,7 +649,8 @@ describe('QuoteService', (): void => { assetCode: asset.code, assetScale: asset.scale, value: incomingAmountValue - } + }, + tenantId: Config.operatorTenantId }) const mockedQuote = mockQuote({ @@ -615,6 +666,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp' @@ -639,11 +691,11 @@ describe('QuoteService', (): void => { scale: 2 }) sendingWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: sendAsset.id }) receivingWalletAddress = await createWalletAddress(deps, { - tenantId: config.operatorTenantId, + tenantId, assetId: receiveAsset.id }) }) @@ -687,6 +739,7 @@ describe('QuoteService', (): void => { .mockResolvedValueOnce(mockedQuote) const quote = await quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -730,6 +783,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -750,10 +804,12 @@ describe('QuoteService', (): void => { test('Local receiver uses local payment method', async () => { const incomingPayment = await createIncomingPayment(deps, { walletAddressId: receivingWalletAddress.id, - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) const options: CreateQuoteOptions = { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: incomingPayment.getUrl(receivingWalletAddress), method: 'ilp' @@ -799,6 +855,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 957c4065f4..e274c43b5e 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -57,6 +57,11 @@ async function getQuote( options: GetOptions ): Promise { const quote = await Quote.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) .get(options) .withGraphFetched('fee') if (quote) { @@ -64,13 +69,15 @@ async function getQuote( if (asset) quote.asset = asset quote.walletAddress = await deps.walletAddressService.get( - quote.walletAddressId + quote.walletAddressId, + quote.tenantId ) } return quote } interface QuoteOptionsBase { + tenantId: string walletAddressId: string receiver: string method: 'ilp' @@ -104,7 +111,8 @@ async function createQuote( return QuoteError.InvalidAmount } const walletAddress = await deps.walletAddressService.get( - options.walletAddressId + options.walletAddressId, + options.tenantId ) if (!walletAddress) { stopTimer() @@ -189,6 +197,7 @@ async function createQuote( const createdQuote = await Quote.query(trx) .insertAndFetch({ id: quoteId, + tenantId: options.tenantId, walletAddressId: options.walletAddressId, assetId: walletAddress.assetId, receiver: options.receiver, @@ -447,6 +456,11 @@ async function getWalletAddressPage( options: ListOptions ): Promise { const quotes = await Quote.query(deps.knex) + .modify((query) => { + if (options.tenantId) { + query.where({ tenantId: options.tenantId }) + } + }) .list(options) .withGraphFetched('fee') for (const quote of quotes) { @@ -454,7 +468,8 @@ async function getWalletAddressPage( if (asset) quote.asset = asset quote.walletAddress = await deps.walletAddressService.get( - quote.walletAddressId + quote.walletAddressId, + quote.tenantId ) } return quotes diff --git a/packages/backend/src/open_payments/receiver/model.test.ts b/packages/backend/src/open_payments/receiver/model.test.ts index 5976465ad5..5ed02b0144 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -42,7 +42,8 @@ describe('Receiver Model', (): void => { tenantId: Config.operatorTenantId }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) const isLocal = true @@ -88,7 +89,8 @@ describe('Receiver Model', (): void => { tenantId: Config.operatorTenantId }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) incomingPayment.state = IncomingPaymentState.Completed @@ -113,7 +115,8 @@ describe('Receiver Model', (): void => { tenantId: Config.operatorTenantId }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) incomingPayment.expiresAt = new Date(Date.now() - 1) @@ -135,7 +138,8 @@ describe('Receiver Model', (): void => { tenantId: Config.operatorTenantId }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) const streamCredentials = streamCredentialsService.get(incomingPayment) diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index debc5c8a56..2077703d1c 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -45,6 +45,7 @@ describe('Receiver Service', (): void => { let streamCredentialsService: StreamCredentialsService let remoteIncomingPaymentService: RemoteIncomingPaymentService let serviceDeps: ServiceDependencies + let tenantId: string beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -66,6 +67,7 @@ describe('Receiver Service', (): void => { streamCredentialsService, telemetry: await deps.use('telemetry') } + tenantId = Config.operatorTenantId }) afterEach(async (): Promise => { @@ -90,7 +92,8 @@ describe('Receiver Service', (): void => { value: BigInt(5), assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) await expect( @@ -315,7 +318,8 @@ describe('Receiver Service', (): void => { walletAddressUrl: walletAddress.url, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) assert(receiver instanceof Receiver) @@ -349,7 +353,8 @@ describe('Receiver Service', (): void => { walletAddressId: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId: Config.operatorTenantId }) expect(remoteIncomingPaymentCreateSpy).not.toHaveBeenCalled() } @@ -362,7 +367,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.url + walletAddressUrl: walletAddress.url, + tenantId }) ).resolves.toEqual(ReceiverError.InvalidAmount) }) @@ -374,7 +380,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.url + walletAddressUrl: walletAddress.url, + tenantId }) ).rejects.toThrow( 'Could not get stream credentials for local incoming payment' @@ -417,12 +424,12 @@ describe('Receiver Service', (): void => { incomingPaymentService, 'create' ) - const receiver = await receiverService.create({ walletAddressUrl: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) expect(receiver).toEqual({ @@ -458,7 +465,8 @@ describe('Receiver Service', (): void => { walletAddressUrl: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) expect(localIncomingPaymentCreateSpy).not.toHaveBeenCalled() } @@ -473,7 +481,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.id + walletAddressUrl: walletAddress.id, + tenantId }) ).resolves.toEqual(ReceiverError.UnknownWalletAddress) }) @@ -493,7 +502,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: mockedIncomingPayment.walletAddress + walletAddressUrl: mockedIncomingPayment.walletAddress, + tenantId }) ).rejects.toThrow('Could not create receiver from incoming payment') expect(remoteIncomingPaymentServiceCreateSpy).toHaveBeenCalledTimes(1) diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index d93c902fda..622edb104b 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -21,6 +21,7 @@ interface CreateReceiverArgs { expiresAt?: Date incomingAmount?: Amount metadata?: Record + tenantId: string } // A receiver is resolved from an incoming payment @@ -97,13 +98,14 @@ async function createLocalIncomingPayment( args: CreateReceiverArgs, walletAddress: WalletAddress ): Promise { - const { expiresAt, incomingAmount, metadata } = args + const { expiresAt, incomingAmount, metadata, tenantId } = args const incomingPaymentOrError = await deps.incomingPaymentService.create({ walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) if (isIncomingPaymentError(incomingPaymentOrError)) { 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 72d0b0dfc5..3ae2438ec1 100644 --- a/packages/backend/src/open_payments/wallet_address/middleware.test.ts +++ b/packages/backend/src/open_payments/wallet_address/middleware.test.ts @@ -31,6 +31,9 @@ import { OutgoingPaymentService } from '../payment/outgoing/service' import { Quote } from '../quote/model' import { IncomingPayment } from '../payment/incoming/model' import { OutgoingPayment } from '../payment/outgoing/model' +import { createOutgoingPayment } from '../../tests/outgoingPayment' +import { createAsset } from '../../tests/asset' +import { AssetOptions } from '../../asset/service' describe('Wallet Address Middleware', (): void => { let deps: IocContract @@ -226,6 +229,58 @@ describe('Wallet Address Middleware', (): void => { expect(next).toHaveBeenCalled() }) + test('throws error if could not find existing quote for mismatched tenantId', async () => { + const tenantId = Config.operatorTenantId + + const asset: AssetOptions = { + scale: 9, + code: 'USD' + } + const { id: sendAssetId } = await createAsset(deps, asset) + const walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: sendAssetId + }) + + const existingQuoteId = ( + await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, + walletAddressId: walletAddress.id, + method: 'ilp', + receiver: `${ + Config.openPaymentsUrl + }/${crypto.randomUUID()}/incoming-payments/${crypto.randomUUID()}`, + debitAmount: { + value: BigInt(456), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + validDestination: false + }) + ).quote.id + + const ctx: WalletAddressUrlContext = createContext( + { headers: { Accept: 'application/json' } }, + { + id: existingQuoteId, + tenantId: crypto.randomUUID() + } + ) + + ctx.container = deps + const next = jest.fn() + + expect.assertions(3) + try { + await getWalletAddressUrlFromQuote(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(401) + expect(err.message).toBe('Unauthorized') + expect(next).not.toHaveBeenCalled() + } + }) + test('throws error if could not find quote', async (): Promise => { const ctx: WalletAddressUrlContext = createContext( { @@ -290,6 +345,58 @@ describe('Wallet Address Middleware', (): void => { expect(next).toHaveBeenCalled() }) + test('throws error if could not find existing outgoing payment for mismatched tenantId', async () => { + const tenantId = Config.operatorTenantId + + const asset: AssetOptions = { + scale: 9, + code: 'USD' + } + const { id: sendAssetId } = await createAsset(deps, asset) + const walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: sendAssetId + }) + + const existingPaymentId = ( + await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, + walletAddressId: walletAddress.id, + method: 'ilp', + receiver: `${ + Config.openPaymentsUrl + }/${crypto.randomUUID()}/incoming-payments/${crypto.randomUUID()}`, + debitAmount: { + value: BigInt(456), + assetCode: walletAddress.asset.code, + assetScale: walletAddress.asset.scale + }, + validDestination: false + }) + ).id + + const ctx: WalletAddressUrlContext = createContext( + { headers: { Accept: 'application/json' } }, + { + id: existingPaymentId, + tenantId: crypto.randomUUID() + } + ) + + ctx.container = deps + const next = jest.fn() + + expect.assertions(3) + try { + await getWalletAddressUrlFromOutgoingPayment(ctx, next) + } catch (err) { + assert(err instanceof OpenPaymentsServerRouteError) + expect(err.status).toBe(401) + expect(err.message).toBe('Unauthorized') + expect(next).not.toHaveBeenCalled() + } + }) + test('throws error if could not find outgoing payment', async (): Promise => { const ctx: WalletAddressUrlContext = createContext( { diff --git a/packages/backend/src/open_payments/wallet_address/middleware.ts b/packages/backend/src/open_payments/wallet_address/middleware.ts index 6a6601d9f9..49fa7c3f21 100644 --- a/packages/backend/src/open_payments/wallet_address/middleware.ts +++ b/packages/backend/src/open_payments/wallet_address/middleware.ts @@ -43,7 +43,8 @@ export async function getWalletAddressUrlFromIncomingPayment( 'incomingPaymentService' ) const incomingPayment = await incomingPaymentService.get({ - id: ctx.params.id + id: ctx.params.id, + tenantId: ctx.params.tenantId }) if (!incomingPayment?.walletAddress) { @@ -65,7 +66,8 @@ export async function getWalletAddressUrlFromOutgoingPayment( 'outgoingPaymentService' ) const outgoingPayment = await outgoingPaymentService.get({ - id: ctx.params.id + id: ctx.params.id, + tenantId: ctx.params.tenantId }) if (!outgoingPayment?.walletAddress) { @@ -85,7 +87,8 @@ export async function getWalletAddressUrlFromQuote( ) { const quoteService = await ctx.container.use('quoteService') const quote = await quoteService.get({ - id: ctx.params.id + id: ctx.params.id, + tenantId: ctx.params.tenantId }) if (!quote?.walletAddress) { diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index c6de194da7..3991c16f53 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -191,6 +191,7 @@ export interface GetOptions { id: string client?: string walletAddressId?: string + tenantId?: string } export interface ListOptions { @@ -198,6 +199,7 @@ export interface ListOptions { client?: string pagination?: Pagination sortOrder?: SortOrder + tenantId?: string } class SubresourceQueryBuilder< 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 c75e5041b9..e30d4056fb 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.test.ts @@ -165,8 +165,8 @@ describe('Wallet Address Routes', (): void => { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, // Ensure the tenant id is returned for auth and resource server: - authServer: `${config.authServerGrantUrl}/${config.operatorTenantId}`, - resourceServer: `${config.openPaymentsUrl}/${config.operatorTenantId}` + authServer: `${config.authServerGrantUrl}/${walletAddress.tenantId}`, + resourceServer: `${config.openPaymentsUrl}/${walletAddress.tenantId}` }) }) }) diff --git a/packages/backend/src/open_payments/wallet_address/routes.ts b/packages/backend/src/open_payments/wallet_address/routes.ts index 339ef3c789..f3b75c989d 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.ts @@ -95,14 +95,16 @@ export const listSubresource = async ({ const page = await getWalletAddressPage({ walletAddressId: ctx.walletAddress.id, pagination, - client + client, + tenantId: ctx.params.tenantId }) const pageInfo = await getPageInfo({ getPage: (pagination) => getWalletAddressPage({ walletAddressId: ctx.walletAddress.id, pagination, - client + client, + tenantId: ctx.params.tenantId }), page, walletAddress: ctx.request.query['wallet-address'] 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 cceeaba6bb..5d6f139996 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -250,7 +250,8 @@ describe('Open Payments Wallet Address Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId: Config.operatorTenantId }) await walletAddressService.update({ @@ -294,7 +295,8 @@ describe('Open Payments Wallet Address Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId: Config.operatorTenantId }) await walletAddressService.update({ diff --git a/packages/backend/src/payment-method/handler/service.test.ts b/packages/backend/src/payment-method/handler/service.test.ts index 6398f06b6f..5bcfaf36ea 100644 --- a/packages/backend/src/payment-method/handler/service.test.ts +++ b/packages/backend/src/payment-method/handler/service.test.ts @@ -45,9 +45,10 @@ describe('PaymentMethodHandlerService', (): void => { describe('getQuote', (): void => { test('calls ilpPaymentService for ILP payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) @@ -74,9 +75,10 @@ describe('PaymentMethodHandlerService', (): void => { ) }) test('calls localPaymentService for local payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) @@ -105,9 +107,10 @@ describe('PaymentMethodHandlerService', (): void => { describe('pay', (): void => { test('calls ilpPaymentService for ILP payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -116,6 +119,7 @@ describe('PaymentMethodHandlerService', (): void => { receivingWalletAddress: walletAddress, method: 'ilp', quoteOptions: { + tenantId, debitAmount: { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, @@ -140,9 +144,10 @@ describe('PaymentMethodHandlerService', (): void => { expect(ilpPaymentServicePaySpy).toHaveBeenCalledWith(options) }) test('calls localPaymentService for local payment type', async (): Promise => { + const tenantId = Config.operatorTenantId const asset = await createAsset(deps) const walletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -151,6 +156,7 @@ describe('PaymentMethodHandlerService', (): void => { receivingWalletAddress: walletAddress, method: 'ilp', quoteOptions: { + tenantId, debitAmount: { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index bba27b8315..48e59a88c4 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -36,6 +36,7 @@ describe('IlpPaymentService', (): void => { let ilpPaymentService: IlpPaymentService let accountingService: AccountingService let config: IAppConfig + let tenantId: string const exchangeRatesUrl = 'https://example-rates.com' @@ -56,6 +57,7 @@ describe('IlpPaymentService', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId assetMap['USD'] = await createAsset(deps, { code: 'USD', scale: 2 @@ -67,12 +69,12 @@ describe('IlpPaymentService', (): void => { }) walletAddressMap['USD'] = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: assetMap['USD'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: assetMap['EUR'].id }) }) @@ -340,7 +342,8 @@ describe('IlpPaymentService', (): void => { quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) } @@ -435,7 +438,8 @@ describe('IlpPaymentService', (): void => { assetCode: 'USD', assetScale: 2, value: 100n - } + }, + tenantId: Config.operatorTenantId }) } @@ -667,6 +671,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -697,6 +702,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, exchangeRate: 1, debitAmount: { value: 100n, @@ -747,6 +753,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -787,6 +794,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -827,6 +835,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -864,6 +873,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, 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 8b479ac893..3092f76491 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 @@ -31,7 +31,8 @@ describe('Stream Credentials Service', (): void => { tenantId: Config.operatorTenantId }) incomingPayment = await createIncomingPayment(deps, { - walletAddressId + walletAddressId, + tenantId: Config.operatorTenantId }) }) diff --git a/packages/backend/src/payment-method/local/service.test.ts b/packages/backend/src/payment-method/local/service.test.ts index c92cb4449d..ee2afc5d2b 100644 --- a/packages/backend/src/payment-method/local/service.test.ts +++ b/packages/backend/src/payment-method/local/service.test.ts @@ -33,6 +33,7 @@ describe('LocalPaymentService', (): void => { let localPaymentService: LocalPaymentService let accountingService: AccountingService let incomingPaymentService: IncomingPaymentService + let tenantId: string const exchangeRatesUrl = 'https://example-rates.com' @@ -53,6 +54,7 @@ describe('LocalPaymentService', (): void => { }) beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId assetMap['USD'] = await createAsset(deps, { code: 'USD', scale: 2 @@ -69,17 +71,17 @@ describe('LocalPaymentService', (): void => { }) walletAddressMap['USD'] = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: assetMap['USD'].id }) walletAddressMap['USD_9'] = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: assetMap['USD_9'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: assetMap['EUR'].id }) }) @@ -250,7 +252,8 @@ describe('LocalPaymentService', (): void => { const options: StartQuoteOptions = { walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) } @@ -409,6 +412,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -439,6 +443,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -476,6 +481,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -515,6 +521,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -554,6 +561,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -593,6 +601,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -633,6 +642,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index 6220c37cfa..85d3df3e01 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -74,6 +74,7 @@ describe('Pagination', (): void => { }) describe('getPageInfo', (): void => { describe('wallet address resources', (): void => { + let tenantId: string let defaultWalletAddress: WalletAddress let secondaryWalletAddress: WalletAddress let debitAmount: Amount @@ -83,13 +84,14 @@ describe('Pagination', (): void => { outgoingPaymentService = await deps.use('outgoingPaymentService') quoteService = await deps.use('quoteService') + tenantId = Config.operatorTenantId const asset = await createAsset(deps) defaultWalletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) secondaryWalletAddress = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId, + tenantId, assetId: asset.id }) debitAmount = { @@ -121,7 +123,8 @@ describe('Pagination', (): void => { const paymentIds: string[] = [] for (let i = 0; i < num; i++) { const payment = await createIncomingPayment(deps, { - walletAddressId: defaultWalletAddress.id + walletAddressId: defaultWalletAddress.id, + tenantId: Config.operatorTenantId }) paymentIds.push(payment.id) } @@ -174,6 +177,7 @@ describe('Pagination', (): void => { const paymentIds: string[] = [] for (let i = 0; i < num; i++) { const payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: defaultWalletAddress.id, receiver: secondaryWalletAddress.url, method: 'ilp', @@ -231,6 +235,7 @@ describe('Pagination', (): void => { const quoteIds: string[] = [] for (let i = 0; i < num; i++) { const quote = await createQuote(deps, { + tenantId, walletAddressId: defaultWalletAddress.id, receiver: secondaryWalletAddress.url, debitAmount, diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 52ba3aa1d6..23b6523003 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -79,7 +79,7 @@ describe('Tenant Service', (): void => { expect(tenant).toEqual(createdTenant) }) - test('returns undefined if tenant is deleted', async (): Promise => { + test('returns deletedAt set if tenant is deleted', async (): Promise => { const dbTenant = await Tenant.query(knex).insertAndFetch({ apiSecret: 'test-secret', email: faker.internet.email(), @@ -90,37 +90,27 @@ describe('Tenant Service', (): void => { const tenant = await tenantService.get(dbTenant.id) expect(tenant).toBeUndefined() - }) - - test('returns tenant settings', async (): Promise => { - const createOptions = { - apiSecret: 'test-api-secret', - publicName: 'test tenant', - email: faker.internet.email(), - idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' - } - jest - .spyOn(authServiceClient.tenant, 'create') - .mockImplementationOnce(async () => undefined) - - const tenant = await tenantService.create(createOptions) + // Ensure Operator is able to access tenant even if deleted: + const tenantDel = await tenantService.get(dbTenant.id, true) + expect(tenantDel?.deletedAt).toBeDefined() + }) - const tenantResponseData = await tenantService.get(tenant.id) - expect(tenantResponseData?.settings?.length).toBeGreaterThan(0) - expect(tenantResponseData?.settings).toEqual([ - expect.objectContaining({ - tenantId: tenant.id, - key: 'WEBHOOK_TIMEOUT', - value: '2000' - }), - expect.objectContaining({ - tenantId: tenant.id, - key: 'WEBHOOK_MAX_RETRY', - value: '10' - }) - ]) + test('returns undefined if tenant is deleted', async (): Promise => { + const dbTenant = await Tenant.query(knex).insertAndFetch({ + apiSecret: 'test-secret', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + deletedAt: new Date() + }) + + const tenant = await tenantService.get(dbTenant.id) + expect(tenant).toBeUndefined() + + // Ensure Operator is able to access tenant even if deleted: + const tenantDel = await tenantService.get(dbTenant.id, true) + expect(tenantDel?.deletedAt).toBeDefined() }) }) @@ -157,35 +147,6 @@ describe('Tenant Service', (): void => { expect(tenantSettings.length).toBeGreaterThan(0) }) - test('should have default settings', async (): Promise => { - const createOptions = { - apiSecret: 'test-api-secret', - publicName: 'test tenant', - email: faker.internet.email(), - idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' - } - - jest - .spyOn(authServiceClient.tenant, 'create') - .mockImplementationOnce(async () => undefined) - - const tenant = await tenantService.create(createOptions) - - expect(tenant.settings).toEqual([ - expect.objectContaining({ - tenantId: tenant.id, - key: 'WEBHOOK_TIMEOUT', - value: '2000' - }), - expect.objectContaining({ - tenantId: tenant.id, - key: 'WEBHOOK_MAX_RETRY', - value: '10' - }) - ]) - }) - test('tenant creation rolls back if auth tenant create fails', async (): Promise => { const createOptions = { apiSecret: 'test-api-secret', @@ -476,6 +437,10 @@ describe('Tenant Service', (): void => { // Ensure that cache was set for deletion expect(spyCacheDelete).toHaveBeenCalledTimes(1) expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) + + // Ensure Operator is able to access tenant even if deleted: + const tenantDel = await tenantService.get(tenant.id, true) + expect(tenantDel?.deletedAt).toBeDefined() } ) ) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 70c37eaac3..64f97fa27a 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -8,7 +8,7 @@ import { TenantSettingService } from './settings/service' import { TenantSetting } from './settings/model' export interface TenantService { - get: (id: string) => Promise + get: (id: string, includeDeleted?: boolean) => Promise create: (options: CreateTenantOptions) => Promise update: (options: UpdateTenantOptions) => Promise delete: (id: string) => Promise @@ -31,7 +31,8 @@ export async function createTenantService( } return { - get: (id: string) => getTenant(deps, id), + get: (id: string, includeDeleted?: boolean) => + getTenant(deps, id, includeDeleted), create: (options) => createTenant(deps, options), update: (options) => updateTenant(deps, options), delete: (id) => deleteTenant(deps, id), @@ -42,13 +43,18 @@ export async function createTenantService( async function getTenant( deps: ServiceDependencies, - id: string + id: string, + includeDeleted: boolean = false ): Promise { const inMem = await deps.tenantCache.get(id) - if (inMem) return inMem - const tenant = await Tenant.query(deps.knex) - .findById(id) - .whereNull('deletedAt') + if (inMem) { + if (!includeDeleted && inMem.deletedAt) return undefined + return inMem + } + let query = Tenant.query(deps.knex) + if (!includeDeleted) query = query.whereNull('deletedAt') + + const tenant = await query.findById(id) if (tenant) await deps.tenantCache.set(tenant.id, tenant) return tenant diff --git a/packages/backend/src/tenants/settings/service.test.ts b/packages/backend/src/tenants/settings/service.test.ts index 8e1cf42599..a95791544e 100644 --- a/packages/backend/src/tenants/settings/service.test.ts +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -11,7 +11,11 @@ import { TenantService } from '../service' import { faker } from '@faker-js/faker' import { getPageTests } from '../../shared/baseModel.test' import { Pagination, SortOrder } from '../../shared/baseModel' -import { createTenantSettings, exchangeRatesSetting, randomSetting } from '../../tests/tenantSettings' +import { + createTenantSettings, + exchangeRatesSetting, + randomSetting +} from '../../tests/tenantSettings' import { TenantSetting } from './model' import { CreateOptions, @@ -193,7 +197,9 @@ describe('TenantSetting Service', (): void => { describe('delete tenant', () => { it('should delete tenant settings if tenant is deleted', async () => { await tenantService.delete(tenant.id) - const found = await Tenant.query().findById(tenant.id).withGraphFetched('settings') + const found = await Tenant.query() + .findById(tenant.id) + .withGraphFetched('settings') for (const tenantSetting of found?.settings as TenantSetting[]) { expect(found?.deletedAt).toEqual(tenantSetting.deletedAt) @@ -216,7 +222,9 @@ describe('TenantSetting Service', (): void => { tenantSetting[0].id ) expect(dbTenantSetting?.deletedAt).toBeDefined() - expect(dbTenantSetting?.deletedAt?.getTime()).toBeLessThanOrEqual(Date.now()) + expect(dbTenantSetting?.deletedAt?.getTime()).toBeLessThanOrEqual( + Date.now() + ) }) test('cannot delete already deleted setting', async (): Promise => { @@ -236,7 +244,7 @@ describe('TenantSetting Service', (): void => { ) expect(dbTenantSetting?.deletedAt).toBeDefined() - const originalDeletedAt = dbTenantSetting?.deletedAt; + const originalDeletedAt = dbTenantSetting?.deletedAt await tenantSettingService.delete({ tenantId: tenantSetting[0].tenantId, key: createOptions.setting[0].key @@ -247,7 +255,9 @@ describe('TenantSetting Service', (): void => { ) expect(dbTenantSetting?.deletedAt).toBeDefined() - expect(originalDeletedAt?.getTime()).toEqual(dbTenantSetting?.deletedAt?.getTime()) + expect(originalDeletedAt?.getTime()).toEqual( + dbTenantSetting?.deletedAt?.getTime() + ) }) test('can delete all tenant settings', async (): Promise => { diff --git a/packages/backend/src/tenants/settings/service.ts b/packages/backend/src/tenants/settings/service.ts index 2e7eafd0f3..ddd4a7e231 100644 --- a/packages/backend/src/tenants/settings/service.ts +++ b/packages/backend/src/tenants/settings/service.ts @@ -62,7 +62,8 @@ export async function createTenantSettingService( create: (options: CreateOptions, extra?: ExtraOptions) => createTenantSetting(deps, options, extra), update: (options: UpdateOptions) => updateTenantSetting(deps, options), - delete: (options: GetOptions, extra?: ExtraOptions) => deleteTenantSetting(deps, options, extra), + delete: (options: GetOptions, extra?: ExtraOptions) => + deleteTenantSetting(deps, options, extra), getPage: ( tenantId: string, pagination?: Pagination, @@ -117,9 +118,8 @@ async function createTenantSetting( options: CreateOptions, extra?: ExtraOptions ) { - const dataToInsert = options.setting - .filter(setting => Object.keys(TenantSettingKeys).includes(setting.key)) + .filter((setting) => Object.keys(TenantSettingKeys).includes(setting.key)) .map((s) => ({ tenantId: options.tenantId, ...s @@ -128,7 +128,7 @@ async function createTenantSetting( if (Object.keys(dataToInsert).length <= 0) { return [] } - + return TenantSetting.query(extra?.trx ?? deps.knex).insertAndFetch( dataToInsert ) diff --git a/packages/backend/src/tests/app.ts b/packages/backend/src/tests/app.ts index 3642de6c1a..4f3dd370c3 100644 --- a/packages/backend/src/tests/app.ts +++ b/packages/backend/src/tests/app.ts @@ -28,36 +28,13 @@ export interface TestContainer { container: IocContract } -export const createTestApp = async ( - container: IocContract -): Promise => { - const config = await container.use('config') - config.adminPort = 0 - config.openPaymentsPort = 0 - config.connectorPort = 0 - config.autoPeeringServerPort = 0 - config.openPaymentsUrl = 'https://op.example' - config.walletAddressUrl = 'https://wallet.example/.well-known/pay' +export const createApolloClient = async ( + container: IocContract, + app: App, + tenantId?: string +): Promise> => { const logger = await container.use('logger') - - const app = new App(container) - await start(container, app) - - const nock = (global as unknown as { nock: typeof import('nock') }).nock - - // Since wallet addresses MUST use HTTPS, manually mock an HTTPS proxy to the Open Payments / SPSP server - nock(config.openPaymentsUrl) - .get(/.*/) - .matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./) - .reply(200, function (path) { - return Axios.get(`http://localhost:${app.getOpenPaymentsPort()}${path}`, { - headers: this.req.headers - }).then((res) => res.data) - }) - .persist() - - const knex = await container.use('knex') - + const config = await container.use('config') const httpLink = createHttpLink({ uri: `http://localhost:${app.getAdminPort()}/graphql`, fetch @@ -80,14 +57,14 @@ export const createTestApp = async ( return { headers: { ...headers, - 'tenant-id': config.operatorTenantId + 'tenant-id': tenantId || config.operatorTenantId } } }) const link = ApolloLink.from([errorLink, authLink, httpLink]) - const client = new ApolloClient({ + return new ApolloClient({ cache: new InMemoryCache({}), link: link, defaultOptions: { @@ -102,6 +79,38 @@ export const createTestApp = async ( } } }) +} + +export const createTestApp = async ( + container: IocContract +): Promise => { + const config = await container.use('config') + config.adminPort = 0 + config.openPaymentsPort = 0 + config.connectorPort = 0 + config.autoPeeringServerPort = 0 + config.openPaymentsUrl = 'https://op.example' + config.walletAddressUrl = 'https://wallet.example/.well-known/pay' + + const app = new App(container) + await start(container, app) + + const nock = (global as unknown as { nock: typeof import('nock') }).nock + + // Since wallet addresses MUST use HTTPS, manually mock an HTTPS proxy to the Open Payments / SPSP server + nock(config.openPaymentsUrl) + .get(/.*/) + .matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./) + .reply(200, function (path) { + return Axios.get(`http://localhost:${app.getOpenPaymentsPort()}${path}`, { + headers: this.req.headers + }).then((res) => res.data) + }) + .persist() + + const knex = await container.use('knex') + + const client = await createApolloClient(container, app) return { app, diff --git a/packages/backend/src/tests/combinedPayment.ts b/packages/backend/src/tests/combinedPayment.ts index bb258e72a3..fbae2901b7 100644 --- a/packages/backend/src/tests/combinedPayment.ts +++ b/packages/backend/src/tests/combinedPayment.ts @@ -54,9 +54,11 @@ export async function createCombinedPayment( const payment = type === PaymentType.Incoming ? await createIncomingPayment(deps, { - walletAddressId: receiveWalletAddress.id + walletAddressId: receiveWalletAddress.id, + tenantId: receiveWalletAddress.tenantId }) : await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: sendWalletAddressId, method: 'ilp', receiver: `${Config.openPaymentsUrl}/${uuid()}`, diff --git a/packages/backend/src/tests/incomingPayment.ts b/packages/backend/src/tests/incomingPayment.ts index b37d93d694..618c0b41c8 100644 --- a/packages/backend/src/tests/incomingPayment.ts +++ b/packages/backend/src/tests/incomingPayment.ts @@ -10,8 +10,12 @@ export async function createIncomingPayment( deps: IocContract, options: CreateIncomingPaymentOptions ): Promise { + const config = await deps.use('config') const incomingPaymentService = await deps.use('incomingPaymentService') - const incomingPaymentOrError = await incomingPaymentService.create(options) + const incomingPaymentOrError = await incomingPaymentService.create({ + ...options, + tenantId: options.tenantId ?? config.operatorTenantId + }) if (isIncomingPaymentError(incomingPaymentOrError)) { throw incomingPaymentOrError } diff --git a/packages/backend/src/tests/outgoingPayment.ts b/packages/backend/src/tests/outgoingPayment.ts index 451f33e6bb..82afb05147 100644 --- a/packages/backend/src/tests/outgoingPayment.ts +++ b/packages/backend/src/tests/outgoingPayment.ts @@ -13,6 +13,7 @@ import { CreateIncomingPaymentOptions } from '../open_payments/payment/incoming/ import { IncomingPayment } from '../open_payments/payment/incoming/model' import { createIncomingPayment } from './incomingPayment' import assert from 'assert' +import { Config } from '../config/app' export type CreateTestQuoteAndOutgoingPaymentOptions = Omit< CreateOutgoingPaymentOptions & CreateTestQuoteOptions, @@ -24,6 +25,7 @@ export async function createOutgoingPayment( options: CreateTestQuoteAndOutgoingPaymentOptions ): Promise { const quoteOptions: CreateTestQuoteOptions = { + tenantId: options.tenantId, walletAddressId: options.walletAddressId, client: options.client, receiver: options.receiver, @@ -40,15 +42,17 @@ export async function createOutgoingPayment( const walletAddressService = await deps.use('walletAddressService') const streamServer = await deps.use('streamServer') const streamCredentials = streamServer.generateCredentials() - - const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: options.walletAddressId - }) - await incomingPayment.$query().delete() const walletAddress = await walletAddressService.get( options.walletAddressId ) assert(walletAddress) + + const incomingPayment = await createIncomingPayment(deps, { + walletAddressId: options.walletAddressId, + tenantId: walletAddress.tenantId + }) + await incomingPayment.$query().delete() + jest .spyOn(receiverService, 'get') .mockResolvedValueOnce( @@ -85,7 +89,7 @@ interface CreateOutgoingPaymentWithReceiverArgs { quoteOptions?: Partial< Pick< CreateTestQuoteAndOutgoingPaymentOptions, - 'debitAmount' | 'receiveAmount' | 'exchangeRate' + 'debitAmount' | 'receiveAmount' | 'exchangeRate' | 'tenantId' > > sendingWalletAddress: WalletAddress @@ -115,7 +119,8 @@ export async function createOutgoingPaymentWithReceiver( const incomingPayment = await createIncomingPayment(deps, { ...args.incomingPaymentOptions, - walletAddressId: args.receivingWalletAddress.id + walletAddressId: args.receivingWalletAddress.id, + tenantId: Config.operatorTenantId }) const streamCredentialsService = await deps.use('streamCredentialsService') @@ -130,6 +135,7 @@ export async function createOutgoingPaymentWithReceiver( ) const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: args.sendingWalletAddress.tenantId, walletAddressId: args.sendingWalletAddress.id, method: args.method, receiver: receiver.incomingPayment!.id!, @@ -140,6 +146,7 @@ export async function createOutgoingPaymentWithReceiver( const outgoingPaymentService = await deps.use('outgoingPaymentService') await outgoingPaymentService.fund({ id: outgoingPayment.id, + tenantId: args.sendingWalletAddress.tenantId, amount: outgoingPayment.debitAmount.value, transferId: uuid() }) diff --git a/packages/backend/src/tests/quote.ts b/packages/backend/src/tests/quote.ts index 5992a66e8b..c49022a21d 100644 --- a/packages/backend/src/tests/quote.ts +++ b/packages/backend/src/tests/quote.ts @@ -57,6 +57,7 @@ export function mockQuote( export async function createQuote( deps: IocContract, { + tenantId, walletAddressId, receiver: receiverUrl, debitAmount, @@ -70,7 +71,10 @@ export async function createQuote( }: CreateTestQuoteOptions ): Promise { const walletAddressService = await deps.use('walletAddressService') - const walletAddress = await walletAddressService.get(walletAddressId) + const walletAddress = await walletAddressService.get( + walletAddressId, + tenantId + ) if (!walletAddress) { throw new Error('wallet not found') } @@ -174,6 +178,7 @@ export async function createQuote( return Quote.query() .insertAndFetch({ id: quoteId, + tenantId, walletAddressId, assetId: walletAddress.assetId, receiver: receiverUrl, diff --git a/packages/backend/src/tests/receiver.ts b/packages/backend/src/tests/receiver.ts index 218051e70d..ca72134d61 100644 --- a/packages/backend/src/tests/receiver.ts +++ b/packages/backend/src/tests/receiver.ts @@ -11,9 +11,11 @@ export async function createReceiver( walletAddress: WalletAddress, options?: Omit ): Promise { + const config = await deps.use('config') const incomingPayment = await createIncomingPayment(deps, { ...options, - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: options?.tenantId ?? config.operatorTenantId }) const streamCredentialsService = await deps.use('streamCredentialsService') diff --git a/packages/backend/src/webhook/service.test.ts b/packages/backend/src/webhook/service.test.ts index a4c878f1dd..d2d55f09e3 100644 --- a/packages/backend/src/webhook/service.test.ts +++ b/packages/backend/src/webhook/service.test.ts @@ -110,6 +110,7 @@ describe('Webhook Service', (): void => { }) describe('Get Webhook Event by account id and types', (): void => { + let tenantId: string let walletAddressIn: WalletAddress let walletAddressOut: WalletAddress let incomingPaymentIds: string[] @@ -117,21 +118,24 @@ describe('Webhook Service', (): void => { let events: WebhookEvent[] = [] beforeEach(async (): Promise => { + tenantId = Config.operatorTenantId walletAddressIn = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId + tenantId }) walletAddressOut = await createWalletAddress(deps, { - tenantId: Config.operatorTenantId + tenantId }) incomingPaymentIds = [ ( await createIncomingPayment(deps, { - walletAddressId: walletAddressIn.id + walletAddressId: walletAddressIn.id, + tenantId: Config.operatorTenantId }) ).id, ( await createIncomingPayment(deps, { - walletAddressId: walletAddressIn.id + walletAddressId: walletAddressIn.id, + tenantId: Config.operatorTenantId }) ).id ] @@ -139,6 +143,7 @@ describe('Webhook Service', (): void => { ( await createOutgoingPayment(deps, { method: 'ilp', + tenantId, walletAddressId: walletAddressOut.id, receiver: '', validDestination: false @@ -147,6 +152,7 @@ describe('Webhook Service', (): void => { ( await createOutgoingPayment(deps, { method: 'ilp', + tenantId, walletAddressId: walletAddressOut.id, receiver: '', validDestination: false diff --git a/packages/frontend/app/components/Sidebar.tsx b/packages/frontend/app/components/Sidebar.tsx index 8555bae1ab..5e37ef29d5 100644 --- a/packages/frontend/app/components/Sidebar.tsx +++ b/packages/frontend/app/components/Sidebar.tsx @@ -17,6 +17,10 @@ const navigation = [ name: 'Home', href: '/' }, + { + name: 'Tenants', + href: '/tenants' + }, { name: 'Assets', href: '/assets' diff --git a/packages/frontend/app/components/ui/Select.tsx b/packages/frontend/app/components/ui/Select.tsx index 70581ec82e..2d604ae374 100644 --- a/packages/frontend/app/components/ui/Select.tsx +++ b/packages/frontend/app/components/ui/Select.tsx @@ -21,6 +21,8 @@ type SelectProps = { error?: string | string[] defaultValue?: SelectOption description?: ReactNode + onChange?: (value: React.SetStateAction) => void + bringForward?: boolean } export const Select = ({ @@ -35,7 +37,9 @@ export const Select = ({ label: '', value: '' }, - description + description, + onChange, + bringForward }: SelectProps) => { const id = useId() const [internalValue, setInternalValue] = useState(defaultValue) @@ -54,13 +58,18 @@ export const Select = ({ return ( { + setInternalValue(value) + if (onChange) { + onChange(value) + } + }} disabled={disabled} > {name ? ( ) : null} -
+
{label ? ( {label} diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index 14c5c66d43..7fd99e6639 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -611,6 +614,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1025,6 +1030,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1268,6 +1275,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1278,6 +1286,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1370,6 +1379,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -2236,6 +2247,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2359,6 +2371,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2460,6 +2473,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2569,6 +2583,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2879,7 +2894,7 @@ export type ListAssetsQueryVariables = Exact<{ }>; -export type ListAssetsQuery = { __typename?: 'Query', assets: { __typename?: 'AssetsConnection', edges: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', code: string, id: string, scale: number, withdrawalThreshold?: bigint | null, createdAt: string } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; +export type ListAssetsQuery = { __typename?: 'Query', assets: { __typename?: 'AssetsConnection', edges: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', code: string, id: string, scale: number, withdrawalThreshold?: bigint | null, createdAt: string, tenantId: string } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; export type CreateAssetMutationVariables = Exact<{ input: CreateAssetInput; @@ -3021,6 +3036,49 @@ export type WithdrawPeerLiquidityVariables = Exact<{ export type WithdrawPeerLiquidity = { __typename?: 'Mutation', createPeerLiquidityWithdrawal?: { __typename?: 'LiquidityMutationResponse', success: boolean } | null }; +export type WhoAmIVariables = Exact<{ [key: string]: never; }>; + + +export type WhoAmI = { __typename?: 'Query', whoami: { __typename?: 'WhoamiResponse', id: string, isOperator: boolean } }; + +export type ListTenantsQueryVariables = Exact<{ + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}>; + + +export type ListTenantsQuery = { __typename?: 'Query', tenants: { __typename?: 'TenantsConnection', edges: Array<{ __typename?: 'TenantEdge', node: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null } }>, pageInfo: { __typename?: 'PageInfo', startCursor?: string | null, endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean } } }; + +export type CreateTenantMutationVariables = Exact<{ + input: CreateTenantInput; +}>; + + +export type CreateTenantMutation = { __typename?: 'Mutation', createTenant: { __typename?: 'TenantMutationResponse', tenant: { __typename?: 'Tenant', id: string, publicName?: string | null, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null } } }; + +export type UpdateTenantMutationVariables = Exact<{ + input: UpdateTenantInput; +}>; + + +export type UpdateTenantMutation = { __typename?: 'Mutation', updateTenant: { __typename?: 'TenantMutationResponse', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null } } }; + +export type DeleteTenantMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type DeleteTenantMutation = { __typename?: 'Mutation', deleteTenant: { __typename?: 'DeleteTenantMutationResponse', success: boolean } }; + +export type GetTenantQueryVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type GetTenantQuery = { __typename?: 'Query', tenant: { __typename?: 'Tenant', id: string, email?: string | null, apiSecret: string, idpConsentUrl?: string | null, idpSecret?: string | null, publicName?: string | null, createdAt: string, deletedAt?: string | null } }; + export type GetWalletAddressQueryVariables = Exact<{ id: Scalars['String']['input']; }>; diff --git a/packages/frontend/app/lib/api/asset.server.ts b/packages/frontend/app/lib/api/asset.server.ts index e11a5d64a3..b4cae9fa02 100644 --- a/packages/frontend/app/lib/api/asset.server.ts +++ b/packages/frontend/app/lib/api/asset.server.ts @@ -123,6 +123,7 @@ export const listAssets = async (request: Request, args: QueryAssetsArgs) => { scale withdrawalThreshold createdAt + tenantId } } pageInfo { diff --git a/packages/frontend/app/lib/api/tenant.server.ts b/packages/frontend/app/lib/api/tenant.server.ts new file mode 100644 index 0000000000..53b59c49d3 --- /dev/null +++ b/packages/frontend/app/lib/api/tenant.server.ts @@ -0,0 +1,209 @@ +import { gql } from '@apollo/client' +import type { + CreateTenantInput, + CreateTenantMutation, + UpdateTenantMutationVariables, + UpdateTenantInput, + UpdateTenantMutation, + CreateTenantMutationVariables, + QueryTenantsArgs, + ListTenantsQuery, + ListTenantsQueryVariables, + DeleteTenantMutationVariables, + DeleteTenantMutation, + QueryTenantArgs, + GetTenantQuery, + GetTenantQueryVariables, + WhoAmI, + WhoAmIVariables +} from '~/generated/graphql' +import { getApolloClient } from '../apollo.server' + +export const whoAmI = async (request: Request) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.query({ + query: gql` + query WhoAmI { + whoami { + id + isOperator + } + } + ` + }) + + return response.data.whoami +} + +export const listTenants = async (request: Request, args: QueryTenantsArgs) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.query< + ListTenantsQuery, + ListTenantsQueryVariables + >({ + query: gql` + query ListTenantsQuery( + $after: String + $before: String + $first: Int + $last: Int + ) { + tenants(after: $after, before: $before, first: $first, last: $last) { + edges { + node { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + createdAt + deletedAt + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + `, + variables: args + }) + return response.data.tenants +} + +export const createTenant = async ( + request: Request, + args: CreateTenantInput +) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.mutate< + CreateTenantMutation, + CreateTenantMutationVariables + >({ + mutation: gql` + mutation CreateTenantMutation($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + publicName + email + apiSecret + idpConsentUrl + idpSecret + } + } + } + `, + variables: { + input: args + } + }) + + return response.data?.createTenant +} + +export const updateTenant = async ( + request: Request, + args: UpdateTenantInput +) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.mutate< + UpdateTenantMutation, + UpdateTenantMutationVariables + >({ + mutation: gql` + mutation UpdateTenantMutation($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: args + } + }) + + return response.data?.updateTenant +} + +export const deleteTenant = async (request: Request, args: string) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.mutate< + DeleteTenantMutation, + DeleteTenantMutationVariables + >({ + mutation: gql` + mutation DeleteTenantMutation($id: String!) { + deleteTenant(id: $id) { + success + } + } + `, + variables: { + id: args + } + }) + + return response.data?.deleteTenant +} + +export const getTenantInfo = async ( + request: Request, + args: QueryTenantArgs +) => { + const apolloClient = await getApolloClient(request) + const response = await apolloClient.query< + GetTenantQuery, + GetTenantQueryVariables + >({ + query: gql` + query GetTenantQuery($id: String!) { + tenant(id: $id) { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + createdAt + deletedAt + } + } + `, + variables: args + }) + return response.data.tenant +} + +export const loadTenants = async (request: Request) => { + let tenants: ListTenantsQuery['tenants']['edges'] = [] + let hasNextPage = true + let after: string | undefined + + while (hasNextPage) { + const response = await listTenants(request, { first: 100, after }) + + if (!response.edges.length) { + return [] + } + if (response.edges) { + tenants = [...tenants, ...response.edges] + } + + hasNextPage = response.pageInfo.hasNextPage + after = response?.pageInfo?.endCursor || tenants[tenants.length - 1].node.id + } + + return tenants +} diff --git a/packages/frontend/app/lib/validate.server.ts b/packages/frontend/app/lib/validate.server.ts index ca74197fe3..8e91af96a2 100644 --- a/packages/frontend/app/lib/validate.server.ts +++ b/packages/frontend/app/lib/validate.server.ts @@ -94,7 +94,7 @@ export const createAssetSchema = z .object({ code: z .string() - .min(3, { message: 'Code should be atleast 3 characters long' }) + .min(3, { message: 'Code should be at least 3 characters long' }) .max(6, { message: 'Maximum length of Code is 6 characters' }) .regex(/^[a-zA-Z]+$/, { message: 'Code should only contain letters.' }) .transform((code) => code.toUpperCase()), @@ -104,7 +104,8 @@ export const createAssetSchema = z }) .int() .min(0, { message: 'Scale should be from 0 to 255' }) - .max(255, { message: 'Scale should be from 0 to 255' }) + .max(255, { message: 'Scale should be from 0 to 255' }), + tenantId: z.string().optional() }) .merge(updateAssetSchema) .omit({ id: true }) @@ -118,7 +119,8 @@ export const amountSchema = z.coerce export const createWalletAddressSchema = z.object({ name: z.string().min(1), publicName: z.string().optional(), - asset: z.string().uuid() + asset: z.string().uuid(), + tenantId: z.string().uuid().optional() }) export const updateWalletAddressSchema = z @@ -127,3 +129,21 @@ export const updateWalletAddressSchema = z status: z.enum([WalletAddressStatus.Active, WalletAddressStatus.Inactive]) }) .merge(uuidSchema) + +export const updateTenantSchema = z + .object({ + apiSecret: z + .string() + .min(10, { message: 'API Secret should be at least 10 characters long' }) + .max(255, { message: 'Maximum length of API Secret is 255 characters' }), + publicName: z.string().optional(), + email: z.string().email().or(z.literal('')), + idpConsentUrl: z.string().optional(), + idpSecret: z.string().optional() + }) + .merge(uuidSchema) + +export const createTenantSchema = z + .object({}) + .merge(updateTenantSchema) + .omit({ id: true }) diff --git a/packages/frontend/app/routes/assets.create.tsx b/packages/frontend/app/routes/assets.create.tsx index 08744e670f..d68b8932fc 100644 --- a/packages/frontend/app/routes/assets.create.tsx +++ b/packages/frontend/app/routes/assets.create.tsx @@ -1,21 +1,38 @@ import { json, type ActionFunctionArgs } from '@remix-run/node' -import { Form, useActionData, useNavigation } from '@remix-run/react' +import { + Form, + useActionData, + useLoaderData, + useNavigation +} from '@remix-run/react' import { PageHeader } from '~/components' -import { Button, ErrorPanel, Input } from '~/components/ui' +import { Button, Select, ErrorPanel, Input } from '~/components/ui' import { createAsset } from '~/lib/api/asset.server' import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' import { createAssetSchema } from '~/lib/validate.server' import type { ZodFieldErrors } from '~/shared/types' import { checkAuthAndRedirect } from '../lib/kratos_checks.server' import { type LoaderFunctionArgs } from '@remix-run/node' +import { whoAmI, loadTenants } from '~/lib/api/tenant.server' +import { getSession } from '~/lib/session.server' export const loader = async ({ request }: LoaderFunctionArgs) => { const cookies = request.headers.get('cookie') await checkAuthAndRedirect(request.url, cookies) - return null + + const session = await getSession(cookies) + const sessionTenantId = session.get('tenantId') + + const { isOperator } = await whoAmI(request) + let tenants + if (isOperator) { + tenants = await loadTenants(request) + } + return json({ tenants, sessionTenantId }) } export default function CreateAssetPage() { + const { tenants, sessionTenantId } = useLoaderData() const response = useActionData() const { state } = useNavigation() const isSubmitting = state === 'submitting' @@ -63,6 +80,22 @@ export default function CreateAssetPage() { label='Withdrawal Threshold' error={response?.errors.fieldErrors.withdrawalThreshold} /> + {tenants && ( + ({ - value: asset.node.id, - label: `${asset.node.code} (Scale: ${asset.node.scale})` - }))} - error={response?.errors.fieldErrors.asset} - name='asset' - placeholder='Select asset...' - label='Asset' - description={ - <> - The type of{' '} - - asset - {' '} - that is sent to & received from the peer. - - } - required - /> + {tenants ? ( + ({ + value: asset.node.id, + label: `${asset.node.code} (Scale: ${asset.node.scale})` + }))} + error={response?.errors.fieldErrors.asset} + name='asset' + placeholder='Select asset...' + label='Asset' + description={ + <> + The type of{' '} + + asset + {' '} + that is sent to & received from the peer. + + } + required + /> + )} + {tenants && tenantId && ( + + + + + +
+
+ {!tenantDeleted && ( + + )} +
+ + +
+ + {/* Identity Provider Information */} +
+
+

+ Identity Provider Information +

+ +
+
+
+
+
+ + + + +
+
+ {!tenantDeleted && ( + + )} +
+
+
+
+
+ {/* Identity Provider Information - END */} + {/* Sensitive Info */} +
+
+

Sensitive Information

+ +
+
+
+
+
+ + +
+
+
+
+
+ {/* Sensitive - END */} + {/* DELETE TENANT - Danger zone */} + {!tenantDeleted && me.isOperator && me.id !== tenant.id && ( + +
+ + + +
+
+ )} + + + + + ) +} + +export async function action({ request }: ActionFunctionArgs) { + const errors: { + fieldErrors: ZodFieldErrors + message: string[] + } = { + fieldErrors: {}, + message: [] + } + + const session = await messageStorage.getSession(request.headers.get('cookie')) + const formData = await request.formData() + const intent = formData.get('intent') + formData.delete('intent') + + switch (intent) { + case 'general': + case 'ip': + case 'sensitive': { + const formEntries = Object.fromEntries(formData) + const result = updateTenantSchema.safeParse(formEntries) + if (!result.success) { + errors.fieldErrors = result.error.flatten().fieldErrors + return json({ errors }, { status: 400 }) + } + + const response = await updateTenant(request, { + ...result.data + }) + + if (!response?.tenant) { + errors.message = ['Could not update tenant. Please try again!'] + return json({ errors }, { status: 400 }) + } + + const me = await whoAmI(request) + // We update the apiSecret of the session in case it changed. + if (formEntries.apiSecret && me.id === formEntries.id) { + session.set('apiSecret', formEntries.apiSecret) + } + break + } + case 'delete': { + const result = uuidSchema.safeParse(Object.fromEntries(formData)) + if (!result.success) { + return setMessageAndRedirect({ + session, + message: { + content: 'Invalid tenant ID.', + type: 'error' + }, + location: '.' + }) + } + + const response = await deleteTenant(request, result.data.id) + if (!response) { + return setMessageAndRedirect({ + session, + message: { + content: 'Could not delete Tenant.', + type: 'error' + }, + location: '.' + }) + } + + return setMessageAndRedirect({ + session, + message: { + content: 'Tenant was deleted.', + type: 'success' + }, + location: '/tenants' + }) + } + default: + throw json(null, { status: 400, statusText: 'Invalid intent.' }) + } + + return setMessageAndRedirect({ + session, + message: { + content: 'Tenant information was updated', + type: 'success' + }, + location: '.' + }) +} diff --git a/packages/frontend/app/routes/tenants._index.tsx b/packages/frontend/app/routes/tenants._index.tsx new file mode 100644 index 0000000000..ef1fdc848d --- /dev/null +++ b/packages/frontend/app/routes/tenants._index.tsx @@ -0,0 +1,157 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData, useNavigate } from '@remix-run/react' +import { Badge, BadgeColor, PageHeader } from '~/components' +import { Button, Table } from '~/components/ui' +import { getTenantInfo, listTenants, whoAmI } from '~/lib/api/tenant.server' +import { paginationSchema } from '~/lib/validate.server' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies) + + const url = new URL(request.url) + const pagination = paginationSchema.safeParse( + Object.fromEntries(url.searchParams.entries()) + ) + + if (!pagination.success) { + throw json(null, { status: 400, statusText: 'Invalid pagination.' }) + } + + const me = await whoAmI(request) + const isOperator = me.isOperator + const tenants = isOperator + ? await listTenants(request, { + ...pagination.data + }) + : undefined + + let previousPageUrl = '', + nextPageUrl = '' + let tenantPageInfo + let tenantEdges + if (tenants) { + if (tenants.pageInfo.hasPreviousPage) + previousPageUrl = `/tenants?before=${tenants.pageInfo.startCursor}` + if (tenants.pageInfo.hasNextPage) + nextPageUrl = `/tenants?after=${tenants.pageInfo.endCursor}` + tenantPageInfo = tenants.pageInfo + tenantEdges = tenants.edges + } else { + const tenantInfo = await getTenantInfo(request, { id: me.id }) + tenantPageInfo = { hasNextPage: false, hasPreviousPage: false } + tenantEdges = [{ node: tenantInfo }] + } + + return json({ + tenantEdges, + tenantPageInfo, + previousPageUrl, + nextPageUrl, + me + }) +} + +export default function TenantsPage() { + const { tenantEdges, tenantPageInfo, previousPageUrl, nextPageUrl, me } = + useLoaderData() + const navigate = useNavigate() + + return ( +
+
+ +
+

Tenants

+
+
+ {me.isOperator && ( + + )} +
+
+ + + + {tenantEdges.length ? ( + tenantEdges.map((tenant) => ( + navigate(`/tenants/${tenant.node.id}`)} + > + +
+
+ + {tenant.node.publicName ? ( + + {tenant.node.publicName} + + ) : ( + + No public name + + )} + + {me.isOperator && me.id == tenant.node.id && ( + Operator + )} +
+
+ (ID: {tenant.node.id}) +
+
+
+ + {tenant.node.email ? ( + {tenant.node.email} + ) : ( + No email + )} + + + {tenant.node.deletedAt ? ( + Inactive + ) : ( + Active + )} + +
+ )) + ) : ( + + + No tenants found. + + + )} +
+
+
+ + +
+
+
+ ) +} diff --git a/packages/frontend/app/routes/tenants.create.tsx b/packages/frontend/app/routes/tenants.create.tsx new file mode 100644 index 0000000000..a68c0a53fa --- /dev/null +++ b/packages/frontend/app/routes/tenants.create.tsx @@ -0,0 +1,158 @@ +import { json, type ActionFunctionArgs, redirect } from '@remix-run/node' +import { + Form, + useActionData, + useLoaderData, + useNavigation +} from '@remix-run/react' +import { PageHeader } from '~/components' +import { Button, ErrorPanel, Input, PasswordInput } from '~/components/ui' +import { createTenant, whoAmI } from '~/lib/api/tenant.server' +import { messageStorage, setMessageAndRedirect } from '~/lib/message.server' +import { createTenantSchema } from '~/lib/validate.server' +import type { ZodFieldErrors } from '~/shared/types' +import { checkAuthAndRedirect } from '../lib/kratos_checks.server' +import { type LoaderFunctionArgs } from '@remix-run/node' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const cookies = request.headers.get('cookie') + await checkAuthAndRedirect(request.url, cookies) + const me = await whoAmI(request) + return json({ me }) +} + +export default function CreateTenantPage() { + const response = useActionData() + const { state } = useNavigation() + const isSubmitting = state === 'submitting' + const { me } = useLoaderData() + if (!me || !me.isOperator) throw redirect('tenants') + + return ( +
+
+ +

Create Tenant

+ +
+ {/* Create Tenant form */} +
+
+ +
+ +
+ {/* Tenant General Info */} +
+
+

General Information

+
+
+
+ + +
+
+
+ {/* Tenant General Info - END */} + {/* Tenant Sensitive Info */} +
+
+

Sensitive Information

+
+
+
+ +
+
+
+ {/* Tenant Sensitive Info - END */} + {/* Tenant Identity Provider */} +
+
+

+ Identity Provider Information +

+
+
+
+ + +
+
+
+ {/* Tenant Identity Provider - End */} +
+ +
+
+
+ {/* Create Tenant form - END */} +
+
+ ) +} + +export async function action({ request }: ActionFunctionArgs) { + const errors: { + fieldErrors: ZodFieldErrors + message: string[] + } = { + fieldErrors: {}, + message: [] + } + + const formData = Object.fromEntries(await request.formData()) + const result = createTenantSchema.safeParse(formData) + if (!result.success) { + errors.fieldErrors = result.error.flatten().fieldErrors + return json({ errors }, { status: 400 }) + } + + const response = await createTenant(request, { ...result.data }) + if (!response?.tenant) { + errors.message = ['Could not create tenant. Please try again!'] + return json({ errors }, { status: 400 }) + } + + const session = await messageStorage.getSession(request.headers.get('cookie')) + + return setMessageAndRedirect({ + session, + message: { + content: 'Tenant created.', + type: 'success' + }, + location: `/tenants/${response.tenant?.id}` + }) +} diff --git a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx index b2d69907f8..77339ff04f 100644 --- a/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx +++ b/packages/frontend/app/routes/wallet-addresses.$walletAddressId.tsx @@ -43,7 +43,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ walletAddress }) } -export default function ViewAssetPage() { +export default function ViewWalletAddressPage() { const { walletAddress } = useLoaderData() const response = useActionData() const navigation = useNavigation() @@ -142,7 +142,7 @@ export default function ViewAssetPage() {

Withdrawal threshold

{walletAddress.asset.withdrawalThreshold ?? - 'No withdrawal threshhold'} + 'No withdrawal threshold'}

diff --git a/packages/frontend/app/routes/wallet-addresses.create.tsx b/packages/frontend/app/routes/wallet-addresses.create.tsx index 59b5f53fb9..174d5ae8ca 100644 --- a/packages/frontend/app/routes/wallet-addresses.create.tsx +++ b/packages/frontend/app/routes/wallet-addresses.create.tsx @@ -1,3 +1,4 @@ +import React, { useState } from 'react' import { json, type ActionFunctionArgs } from '@remix-run/node' import { Form, @@ -6,6 +7,7 @@ import { useNavigation } from '@remix-run/react' import { PageHeader } from '~/components' +import type { SelectOption } from '~/components/ui' import { Button, ErrorPanel, Input, Select } from '~/components/ui' import { loadAssets } from '~/lib/api/asset.server' import { createWalletAddress } from '~/lib/api/wallet-address.server' @@ -18,18 +20,37 @@ import { } from '~/shared/utils' import { checkAuthAndRedirect } from '../lib/kratos_checks.server' import { type LoaderFunctionArgs } from '@remix-run/node' +import { whoAmI, loadTenants } from '~/lib/api/tenant.server' export const loader = async ({ request }: LoaderFunctionArgs) => { const cookies = request.headers.get('cookie') await checkAuthAndRedirect(request.url, cookies) - return json({ assets: await loadAssets(request) }) + + const assets = await loadAssets(request) + const { isOperator } = await whoAmI(request) + let tenants + if (isOperator) { + tenants = await loadTenants(request) + } + return json({ assets, tenants }) } export default function CreateWalletAddressPage() { - const { assets } = useLoaderData() + const { assets, tenants } = useLoaderData() const response = useActionData() const { state } = useNavigation() const isSubmitting = state === 'submitting' + const [tenantId, setTenantId] = useState() + + const getAssetsOfTenant = (): SelectOption[] => { + const assetsOfTenant = assets.filter( + (asset) => asset.node.tenantId === tenantId?.value + ) + return assetsOfTenant.map((asset) => ({ + value: asset.node.id, + label: `${asset.node.code} (Scale: ${asset.node.scale})` + })) + } return (
@@ -68,17 +89,42 @@ export default function CreateWalletAddressPage() { placeholder='Public name' error={response?.errors?.fieldErrors.publicName} /> - ({ + value: tenant.node.id, + label: `${tenant.node.id} ${tenant.node.publicName ? `(${tenant.node.publicName})` : ''}` + }))} + name='tenantId' + placeholder='Select tenant...' + label='Tenant' + required + onChange={(value) => setTenantId(value)} + bringForward + /> + ) : ( + + )}
@@ -119,6 +165,7 @@ export async function action({ request }: ActionFunctionArgs) { url: `${baseUrl}/${path}`, publicName: result.data.publicName, assetId: result.data.asset, + tenantId: result.data.tenantId, additionalProperties: [] }) diff --git a/packages/frontend/app/styles/tailwind.css b/packages/frontend/app/styles/tailwind.css index 45781af4fa..ba6adb470b 100644 --- a/packages/frontend/app/styles/tailwind.css +++ b/packages/frontend/app/styles/tailwind.css @@ -6,3 +6,7 @@ a.default-link { color: revert; text-decoration: revert; } + +div.forward { + z-index: 1; +} diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 99ff7f0619..21d00b3971 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -611,6 +614,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1025,6 +1030,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1268,6 +1275,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1278,6 +1286,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1370,6 +1379,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -2236,6 +2247,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2359,6 +2371,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2460,6 +2473,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2569,6 +2583,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/test/integration/integration.test.ts b/test/integration/integration.test.ts index d83df3e03f..417a1e703f 100644 --- a/test/integration/integration.test.ts +++ b/test/integration/integration.test.ts @@ -116,7 +116,7 @@ describe('Integration tests', (): void => { const incomingPayment = await createIncomingPayment( receiverWalletAddress, incomingPaymentGrant.access_token.value, - { amountValueToSend } + { amountValueToSend, tenantId: hlb.config.operatorTenantId } ) const quoteGrant = await grantRequestQuote(senderWalletAddress) const quote = await createQuote( @@ -193,7 +193,7 @@ describe('Integration tests', (): void => { const incomingPayment = await createIncomingPayment( receiverWalletAddress, incomingPaymentGrant.access_token.value, - { amountValueToSend } + { amountValueToSend, tenantId: hlb.config.operatorTenantId } ) const quoteGrant = await grantRequestQuote(senderWalletAddress) const quote = await createQuote( @@ -276,7 +276,8 @@ describe('Integration tests', (): void => { ) const incomingPayment = await createIncomingPayment( receiverWalletAddress, - incomingPaymentGrant.access_token.value + incomingPaymentGrant.access_token.value, + { tenantId: hlb.config.operatorTenantId } ) const outgoingPaymentGrant = await grantRequestOutgoingPayment( diff --git a/test/integration/lib/apollo-client.ts b/test/integration/lib/apollo-client.ts index aa7f206511..0d3276326d 100644 --- a/test/integration/lib/apollo-client.ts +++ b/test/integration/lib/apollo-client.ts @@ -1,26 +1,32 @@ import type { NormalizedCacheObject } from '@apollo/client' import { ApolloClient, - ApolloLink, + InMemoryCache, createHttpLink, - InMemoryCache + ApolloLink } from '@apollo/client' -import { createHmac } from 'crypto' +import { setContext } from '@apollo/client/link/context' import { print } from 'graphql/language/printer' +import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' -import { setContext } from '@apollo/client/link/context' interface CreateApolloClientArgs { graphqlUrl: string - signatureSecret: string - signatureVersion: string operatorTenantId: string + signatureSecret?: string + signatureVersion?: string } -function createAuthLink(args: CreateApolloClientArgs) { - return setContext((request, { headers }) => { - const timestamp = Math.round(new Date().getTime() / 1000) - const version = args.signatureVersion +export function createApolloClient( + args: CreateApolloClientArgs +): ApolloClient { + const httpLink = createHttpLink({ + uri: args.graphqlUrl + }) + + const authLink = setContext((request, { headers }) => { + if (!args.signatureSecret || !args.signatureVersion) return { headers } + const timestamp = Date.now() const { query, variables, operationName } = request const formattedRequest = { @@ -36,22 +42,13 @@ function createAuthLink(args: CreateApolloClientArgs) { return { headers: { ...headers, - signature: `t=${timestamp}, v${version}=${digest}`, + signature: `t=${timestamp}, v${args.signatureVersion}=${digest}`, 'tenant-id': args.operatorTenantId } } }) -} - -export function createApolloClient( - args: CreateApolloClientArgs -): ApolloClient { - const httpLink = createHttpLink({ - uri: args.graphqlUrl - }) return new ApolloClient({ - link: ApolloLink.from([createAuthLink(args), httpLink]), cache: new InMemoryCache(), link: ApolloLink.from([authLink, httpLink]), defaultOptions: { diff --git a/test/integration/lib/generated/graphql.ts b/test/integration/lib/generated/graphql.ts index 99ff7f0619..21d00b3971 100644 --- a/test/integration/lib/generated/graphql.ts +++ b/test/integration/lib/generated/graphql.ts @@ -124,6 +124,7 @@ export type Asset = Model & { scale: Scalars['UInt8']['output']; /** The sending fee structure for the asset. */ sendingFee?: Maybe; + tenantId: Scalars['ID']['output']; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: Maybe; }; @@ -199,6 +200,8 @@ export type CreateAssetInput = { liquidityThreshold?: InputMaybe; /** Difference in order of magnitude between the standard unit of an asset and its corresponding fractional unit. */ scale: Scalars['UInt8']['input']; + /** Unique identifier of the tenant associated with the asset. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Minimum amount of liquidity that can be withdrawn from the asset. */ withdrawalThreshold?: InputMaybe; }; @@ -611,6 +614,8 @@ export type IncomingPayment = BasePayment & Model & { receivedAmount: Amount; /** State of the incoming payment. */ state: IncomingPaymentState; + /** The tenant UUID associated with the incoming payment. If not provided, it will be obtained from the signature. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the incoming payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1025,6 +1030,8 @@ export type OutgoingPayment = BasePayment & Model & { state: OutgoingPaymentState; /** Number of attempts made to send an outgoing payment. */ stateAttempts: Scalars['Int']['output']; + /** Tenant ID of the outgoing payment. */ + tenantId?: Maybe; /** Unique identifier of the wallet address under which the outgoing payment was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -1268,6 +1275,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1278,6 +1286,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1370,6 +1379,8 @@ export type Quote = { receiveAmount: Amount; /** Wallet address URL of the receiver. */ receiver: Scalars['String']['output']; + /** Unique identifier of the tenant under which the quote was created. */ + tenantId: Scalars['ID']['output']; /** Unique identifier of the wallet address under which the quote was created. */ walletAddressId: Scalars['ID']['output']; }; @@ -2236,6 +2247,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2359,6 +2371,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2460,6 +2473,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2569,6 +2583,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/test/integration/lib/test-actions/open-payments.ts b/test/integration/lib/test-actions/open-payments.ts index 0f3dc23c61..b789e2af20 100644 --- a/test/integration/lib/test-actions/open-payments.ts +++ b/test/integration/lib/test-actions/open-payments.ts @@ -12,13 +12,7 @@ import { isPendingGrant } from '@interledger/open-payments' import { MockASE } from '../mock-ase' -import { - UnionOmit, - poll, - pollCondition, - wait, - urlWithoutTenantId -} from '../utils' +import { UnionOmit, poll, pollCondition, wait } from '../utils' import { WebhookEventType } from 'mock-account-service-lib' import { CreateOutgoingPaymentArgs, @@ -104,7 +98,7 @@ async function grantRequestIncomingPayment( const grant = await sendingASE.opClient.grant.request( { - url: urlWithoutTenantId(receiverWalletAddress.authServer) + url: receiverWalletAddress.authServer }, { access_token: { @@ -123,6 +117,7 @@ async function grantRequestIncomingPayment( type CreateIncomingPaymentOpts = { amountValueToSend?: string + tenantId?: string } async function createIncomingPayment( @@ -158,7 +153,7 @@ async function createIncomingPayment( const incomingPayment = await sendingASE.opClient.incomingPayment.create( { - url: urlWithoutTenantId(receiverWalletAddress.resourceServer), + url: receiverWalletAddress.resourceServer, accessToken }, createInput @@ -191,7 +186,7 @@ async function grantRequestQuote( const { sendingASE } = deps const grant = await sendingASE.opClient.grant.request( { - url: urlWithoutTenantId(senderWalletAddress.authServer) + url: senderWalletAddress.authServer }, { access_token: { @@ -217,7 +212,7 @@ async function createQuote( const { sendingASE } = deps return await sendingASE.opClient.quote.create( { - url: urlWithoutTenantId(senderWalletAddress.resourceServer), + url: senderWalletAddress.resourceServer, accessToken }, { @@ -241,7 +236,7 @@ async function grantRequestOutgoingPayment( const { receivingASE } = deps const grant = await receivingASE.opClient.grant.request( { - url: urlWithoutTenantId(senderWalletAddress.authServer) + url: senderWalletAddress.authServer }, { access_token: { @@ -325,7 +320,7 @@ async function createOutgoingPayment( const outgoingPayment = await sendingASE.opClient.outgoingPayment.create( { - url: urlWithoutTenantId(senderWalletAddress.resourceServer), + url: senderWalletAddress.resourceServer, accessToken: grantContinue.access_token.value }, { diff --git a/test/integration/testenv/cloud-nine-wallet/.env b/test/integration/testenv/cloud-nine-wallet/.env index 81332fc6f0..23aa3996c0 100644 --- a/test/integration/testenv/cloud-nine-wallet/.env +++ b/test/integration/testenv/cloud-nine-wallet/.env @@ -9,6 +9,4 @@ SIGNATURE_VERSION=1 SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= OPERATOR_TENANT_ID=438fa74a-fa7d-4317-9ced-dde32ece1787 # matches pfry key id -KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 -SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= -SIGNATURE_VERSION=1 \ No newline at end of file +KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file diff --git a/test/integration/testenv/happy-life-bank/.env b/test/integration/testenv/happy-life-bank/.env index 493515a422..4037251585 100644 --- a/test/integration/testenv/happy-life-bank/.env +++ b/test/integration/testenv/happy-life-bank/.env @@ -9,6 +9,4 @@ SIGNATURE_VERSION=1 SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= OPERATOR_TENANT_ID=cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d # matches pfry key id -KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 -SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= -SIGNATURE_VERSION=1 \ No newline at end of file +KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file