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/Create Tenant Settings.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant Settings.bru new file mode 100644 index 0000000000..757b275e6e --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant Settings.bru @@ -0,0 +1,44 @@ +meta { + name: Create Tenant Settings + type: graphql + seq: 58 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +headers { + tenant-id: 438fa74a-fa7d-4317-9ced-dde32ece1787 +} + +body:graphql { + mutation CreateTenantSettings($input: [CreateTenantSettingsInput!]!) { + createTenantSettings(input:$input) { + settings { + key + value + } + } + } +} + +body:graphql:vars { + { + "input": [ + { + "key": "MY_KEY", + "value": "MY_VALUE" + } + ] + } + +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru new file mode 100644 index 0000000000..c078d15371 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Create Tenant.bru @@ -0,0 +1,50 @@ +meta { + name: Create Tenant + type: graphql + seq: 54 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input:$input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + } + } + } +} + +body:graphql:vars { + { + "input": { + "email": "example@example.com", + "apiSecret": "test-secret", + "idpConsentUrl": "https://example.com/consent", + "idpSecret": "test-idp-secret" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} + +script:post-response { + const body = res.getBody(); + + if (body?.data) { + bru.setEnvVar("tenantId", body.data.createTenant.tenant?.id); + } +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Delete Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Delete Tenant.bru new file mode 100644 index 0000000000..6c664049f7 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Delete Tenant.bru @@ -0,0 +1,31 @@ +meta { + name: Delete Tenant + type: graphql + seq: 56 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation DeleteTenant($id: String!) { + deleteTenant(id:$id) { + success + } + } +} + +body:graphql:vars { + { + "id": "{{tenantId}}" + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} 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/Rafiki Admin APIs/Get Tenants.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Tenants.bru new file mode 100644 index 0000000000..53f3656c4c --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Get Tenants.bru @@ -0,0 +1,43 @@ +meta { + name: Get Tenants + type: graphql + seq: 57 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input:$input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + } + } + } +} + +body:graphql:vars { + { + "input": { + "id": "{{tenantId}}", + "email": "updated@example.com", + "apiSecret": "updated-test-secret", + "idpConsentUrl": "https://example.com/consent-updated", + "idpSecret": "updated-test-idp-secret" + } + } +} + +script:pre-request { + const scripts = require('./scripts'); + + scripts.addApiSignatureHeader(); +} diff --git a/bruno/collections/Rafiki/Rafiki Admin APIs/Update Tenant.bru b/bruno/collections/Rafiki/Rafiki Admin APIs/Update Tenant.bru new file mode 100644 index 0000000000..b3f71689d8 --- /dev/null +++ b/bruno/collections/Rafiki/Rafiki Admin APIs/Update Tenant.bru @@ -0,0 +1,43 @@ +meta { + name: Update Tenant + type: graphql + seq: 55 +} + +post { + url: {{RafikiGraphqlHost}}/graphql + body: graphql + auth: none +} + +body:graphql { + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input:$input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + } + } + } +} + +body:graphql:vars { + { + "input": { + "id": "{{tenantId}}", + "email": "updated@example.com", + "apiSecret": "updated-test-secret", + "idpConsentUrl": "https://example.com/consent-updated", + "idpSecret": "updated-test-idp-secret" + } + } +} + +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 6cb1033ff6..b637cb0bae 100644 --- a/bruno/collections/Rafiki/environments/Local Playground.bru +++ b/bruno/collections/Rafiki/environments/Local Playground.bru @@ -30,4 +30,6 @@ vars { assetIdTigerBeetle: 1 assetCode: USD assetScale: 2 + senderTenantId: 438fa74a-fa7d-4317-9ced-dde32ece1787 + RafikiGraphqlHostTenantId: 438fa74a-fa7d-4317-9ced-dde32ece1787 } diff --git a/bruno/collections/Rafiki/scripts.js b/bruno/collections/Rafiki/scripts.js index aeff1000a2..654a252ca2 100644 --- a/bruno/collections/Rafiki/scripts.js +++ b/bruno/collections/Rafiki/scripts.js @@ -127,6 +127,7 @@ const scripts = { signature = this.generateBackendApiSignature(formattedBody) } req.setHeader('signature', signature) + req.setHeader('tenant-id', bru.getEnvVar('senderTenantId')) }, addHostHeader: function (hostVarName) { 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/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 3fa672a106..6b2bed59eb 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -25,6 +25,8 @@ services: IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= DISPLAY_NAME: Cloud Nine Wallet DISPLAY_ICON: wallet-icon.svg + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + FRONTEND_PORT: 3010 volumes: - ../cloud-nine-wallet/seed.yml:/workspace/seed.yml - ../cloud-nine-wallet/private-key.pem:/workspace/private-key.pem @@ -65,6 +67,9 @@ services: TIGERBEETLE_REPLICA_ADDRESSES: ${TIGERBEETLE_REPLICA_ADDRESSES-''} AUTH_SERVER_GRANT_URL: ${CLOUD_NINE_AUTH_SERVER_DOMAIN:-http://cloud-nine-wallet-auth:3006} AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-auth:3007 + AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-auth:3003/graphql' + AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=' + AUTH_SERVICE_API_URL: 'http://cloud-nine-wallet-auth:3011' ILP_ADDRESS: ${ILP_ADDRESS:-test.cloud-nine-wallet} STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= @@ -76,6 +81,7 @@ services: ILP_CONNECTOR_URL: ${CLOUD_NINE_CONNECTOR_URL:-http://cloud-nine-wallet-backend:3002} ENABLE_TELEMETRY: true KEY_ID: 7097F83B-CB84-469E-96C6-2141C72E22C0 + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 depends_on: - shared-database - shared-redis @@ -105,6 +111,7 @@ services: - '3006:3006' - "9230:9229" - '3009:3009' + - '3011:3011' environment: NODE_ENV: ${NODE_ENV:-development} TRUST_PROXY: ${TRUST_PROXY} @@ -115,6 +122,8 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + SERVICE_API_PORT: 3011 depends_on: - shared-database - shared-redis @@ -159,9 +168,8 @@ services: GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql OPEN_PAYMENTS_URL: https://cloud-nine-wallet-backend/ ENABLE_INSECURE_MESSAGE_COOKIE: true - AUTH_ENABLED: false + AUTH_ENABLED: false SIGNATURE_VERSION: 1 - SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= depends_on: - cloud-nine-backend diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 0ca6b5a86e..7de52b6086 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -21,6 +21,8 @@ services: IDP_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= DISPLAY_NAME: Happy Life Bank DISPLAY_ICON: bank-icon.svg + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + FRONTEND_PORT: 4010 volumes: - ../happy-life-bank/seed.yml:/workspace/seed.yml - ../happy-life-bank/private-key.pem:/workspace/private-key.pem @@ -58,6 +60,9 @@ services: USE_TIGERBEETLE: false AUTH_SERVER_GRANT_URL: ${HAPPY_LIFE_BANK_AUTH_SERVER_DOMAIN:-http://happy-life-bank-auth:3006} AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-auth:3007 + AUTH_ADMIN_API_URL: 'http://happy-life-bank-auth:4003/graphql' + AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=' + AUTH_SERVICE_API_URL: 'http://happy-life-bank-auth:4011' ILP_ADDRESS: test.happy-life-bank ILP_CONNECTOR_URL: http://happy-life-bank-backend:4002 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= @@ -69,6 +74,7 @@ services: WALLET_ADDRESS_URL: ${HAPPY_LIFE_BANK_WALLET_ADDRESS_URL:-https://happy-life-bank-backend/.well-known/pay} ENABLE_TELEMETRY: true KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23d + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d depends_on: - cloud-nine-backend healthcheck: @@ -95,6 +101,7 @@ services: - '4006:3006' - '9232:9229' - '4009:3009' + - '4011:4011' environment: NODE_ENV: development AUTH_DATABASE_URL: postgresql://happy_life_bank_auth:happy_life_bank_auth@shared-database/happy_life_bank_auth @@ -104,6 +111,8 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + SERVICE_API_PORT: 4011 depends_on: - cloud-nine-auth happy-life-admin: @@ -129,7 +138,6 @@ services: ENABLE_INSECURE_MESSAGE_COOKIE: true AUTH_ENABLED: false SIGNATURE_VERSION: 1 - SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= depends_on: - cloud-nine-admin - happy-life-backend diff --git a/localenv/mock-account-servicing-entity/app/entry.server.tsx b/localenv/mock-account-servicing-entity/app/entry.server.tsx index 0183875b14..46b3c9710f 100644 --- a/localenv/mock-account-servicing-entity/app/entry.server.tsx +++ b/localenv/mock-account-servicing-entity/app/entry.server.tsx @@ -30,6 +30,15 @@ async function callWithRetry(fn: () => any, depth = 0): Promise { } if (!global.__seeded) { + const tenantId = process.env.OPERATOR_TENANT_ID + const apiSecret = process.env.SIGNATURE_SECRET + + if (!tenantId || !apiSecret) { + throw new Error( + 'Must set OPERATOR_TENANT_ID and SIGNATURE_SECRET environment variables' + ) + } + callWithRetry(async () => { console.log('setting up from seed...') return setupFromSeed(CONFIG, apolloClient, mockAccounts, { @@ -39,6 +48,19 @@ if (!global.__seeded) { }) .then(() => { global.__seeded = true + setTimeout(() => { + const url = new URL(`http://localhost:${process.env.FRONTEND_PORT}/`) + const params = new URLSearchParams({ + tenantId, + apiSecret + }) + + url.search = params.toString() + + console.log( + `Local Dev Setup:\nUse this URL to access the frontend with operator tenant credentials:\n${url}\n` + ) + }, 2000) }) .catch((e) => { console.log( diff --git a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts index 505aca43b9..67a5fa3049 100644 --- a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts +++ b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts @@ -68,7 +68,8 @@ const authLink = setContext((request, { headers }) => { return { headers: { ...headers, - signature: `t=${timestamp}, v${version}=${digest}` + signature: `t=${timestamp}, v${version}=${digest}`, + ['tenant-id']: process.env.OPERATOR_TENANT_ID } } }) diff --git a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts index e4a934def3..5ab802e182 100644 --- a/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts +++ b/localenv/mock-account-servicing-entity/app/lib/parse_config.server.ts @@ -15,6 +15,10 @@ if (!process.env.IDP_SECRET) { throw new Error('Environment variable IDP_SECRET is required') } +if (!process.env.OPERATOR_TENANT_ID) { + throw new Error('Environment variable OPERATOR_TENANT_ID is required') +} + export const CONFIG: Config = { seed: parse( readFileSync( @@ -26,5 +30,6 @@ export const CONFIG: Config = { testnetAutoPeerUrl: process.env.TESTNET_AUTOPEER_URL ?? '', authServerDomain: process.env.AUTH_SERVER_DOMAIN || 'http://localhost:3006', graphqlUrl: process.env.GRAPHQL_URL, - idpSecret: process.env.IDP_SECRET + idpSecret: process.env.IDP_SECRET, + operatorTenantId: process.env.OPERATOR_TENANT_ID } diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 46dad2dedb..f1c22ba2a9 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; }; @@ -364,6 +367,32 @@ export type CreateReceiverResponse = { receiver?: Maybe; }; +export type CreateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['input']; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; + /** Initial settings for tenant. */ + settings?: InputMaybe>; +}; + +export type CreateTenantSettingsInput = { + /** List of a settings for a tenant. */ + settings: Array; +}; + +export type CreateTenantSettingsMutationResponse = { + __typename?: 'CreateTenantSettingsMutationResponse'; + /** New tenant settings. */ + settings: Array; +}; + export type CreateWalletAddressInput = { /** Additional properties associated with the wallet address. */ additionalProperties?: InputMaybe>; @@ -373,6 +402,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -440,6 +471,11 @@ export type DeletePeerMutationResponse = { success: Scalars['Boolean']['output']; }; +export type DeleteTenantMutationResponse = { + __typename?: 'DeleteTenantMutationResponse'; + success: Scalars['Boolean']['output']; +}; + export type DepositAssetLiquidityInput = { /** Amount of liquidity to deposit. */ amount: Scalars['UInt64']['input']; @@ -580,6 +616,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']; }; @@ -721,6 +759,9 @@ export type Mutation = { createQuote: QuoteResponse; /** Create an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ createReceiver: CreateReceiverResponse; + /** As an operator, create a tenant. */ + createTenant: TenantMutationResponse; + createTenantSettings?: Maybe; /** Create a new wallet address. */ createWalletAddress: CreateWalletAddressMutationResponse; /** Add a public key to a wallet address that is used to verify Open Payments requests. */ @@ -731,6 +772,8 @@ export type Mutation = { deleteAsset: DeleteAssetMutationResponse; /** Delete a peer. */ deletePeer: DeletePeerMutationResponse; + /** Delete a tenant. */ + deleteTenant: DeleteTenantMutationResponse; /** Deposit asset liquidity. */ depositAssetLiquidity?: Maybe; /** @@ -756,6 +799,8 @@ export type Mutation = { updateIncomingPayment: IncomingPaymentResponse; /** Update an existing peer. */ updatePeer: UpdatePeerMutationResponse; + /** Update a tenant. */ + updateTenant: TenantMutationResponse; /** Update an existing wallet address. */ updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ @@ -843,6 +888,16 @@ export type MutationCreateReceiverArgs = { }; +export type MutationCreateTenantArgs = { + input: CreateTenantInput; +}; + + +export type MutationCreateTenantSettingsArgs = { + input: CreateTenantSettingsInput; +}; + + export type MutationCreateWalletAddressArgs = { input: CreateWalletAddressInput; }; @@ -868,6 +923,11 @@ export type MutationDeletePeerArgs = { }; +export type MutationDeleteTenantArgs = { + id: Scalars['String']['input']; +}; + + export type MutationDepositAssetLiquidityArgs = { input: DepositAssetLiquidityInput; }; @@ -923,6 +983,11 @@ export type MutationUpdatePeerArgs = { }; +export type MutationUpdateTenantArgs = { + input: UpdateTenantInput; +}; + + export type MutationUpdateWalletAddressArgs = { input: UpdateWalletAddressInput; }; @@ -967,6 +1032,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']; }; @@ -1150,6 +1217,10 @@ export type Query = { quote?: Maybe; /** Retrieve an Open Payments incoming payment by receiver ID. The receiver's wallet address can be hosted on this server or a remote Open Payments resource server. */ receiver?: Maybe; + /** Retrieve a tenant of the instance. */ + tenant: Tenant; + /** As an operator, fetch a paginated list of tenants on the instance. */ + tenants: TenantsConnection; /** Fetch a wallet address by its ID. */ walletAddress?: Maybe; /** Get a wallet address by its url if it exists */ @@ -1158,6 +1229,8 @@ export type Query = { walletAddresses: WalletAddressesConnection; /** Fetch a paginated list of webhook events. */ webhookEvents: WebhookEventsConnection; + /** Determine if the requester has operator permissions */ + whoami: WhoamiResponse; }; @@ -1204,6 +1277,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1214,6 +1288,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1247,6 +1322,20 @@ export type QueryReceiverArgs = { }; +export type QueryTenantArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryTenantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + sortOrder?: InputMaybe; +}; + + export type QueryWalletAddressArgs = { id: Scalars['String']['input']; }; @@ -1263,6 +1352,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1291,6 +1381,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']; }; @@ -1376,6 +1468,89 @@ export enum SortOrder { Desc = 'DESC' } +export type Tenant = Model & { + __typename?: 'Tenant'; + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['output']; + /** The date and time that this tenant was created. */ + createdAt: Scalars['String']['output']; + /** The date and time that this tenant was deleted. */ + deletedAt?: Maybe; + /** Contact email of the tenant owner. */ + email?: Maybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['output']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: Maybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: Maybe; + /** Public name for the tenant. */ + publicName?: Maybe; + /** List of settings for the tenant. */ + settings?: Maybe; +}; + + +export type TenantSettingsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + sortOrder?: InputMaybe; +}; + +export type TenantEdge = { + __typename?: 'TenantEdge'; + /** A cursor for paginating through the tenants. */ + cursor: Scalars['String']['output']; + /** A tenant node in the list. */ + node: Tenant; +}; + +export type TenantMutationResponse = { + __typename?: 'TenantMutationResponse'; + tenant: Tenant; +}; + +export type TenantSetting = { + __typename?: 'TenantSetting'; + /** Key for this setting. */ + key: Scalars['String']['output']; + /** Value of a setting for this key. */ + value: Scalars['String']['output']; +}; + +export type TenantSettingEdge = { + __typename?: 'TenantSettingEdge'; + /** A cursor for paginating through the tenants. */ + cursor: Scalars['String']['output']; + /** A tenant setting node in the list. */ + node: TenantSetting; +}; + +export type TenantSettingInput = { + /** Key for this setting. */ + key: Scalars['String']['input']; + /** Value of a setting for this key. */ + value: Scalars['String']['input']; +}; + +export type TenantSettingsConnection = { + __typename?: 'TenantSettingsConnection'; + /** A list of edges representing tenant settings and cursors for pagination. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +export type TenantsConnection = { + __typename?: 'TenantsConnection'; + /** A list of edges representing tenants and cursors for pagination. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + export enum TransferState { /** The accounting transfer is pending */ Pending = 'PENDING', @@ -1448,6 +1623,21 @@ export type UpdatePeerMutationResponse = { peer?: Maybe; }; +export type UpdateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret?: InputMaybe; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['input']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; +}; + export type UpdateWalletAddressInput = { /** Additional properties associated with this wallet address. */ additionalProperties?: InputMaybe>; @@ -1496,6 +1686,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -1640,6 +1832,12 @@ export type WebhookEventsEdge = { node: WebhookEvent; }; +export type WhoamiResponse = { + __typename?: 'WhoamiResponse'; + id: Scalars['String']['output']; + isOperator: Scalars['Boolean']['output']; +}; + export type WithdrawEventLiquidityInput = { /** Unique identifier of the event to withdraw liquidity from. */ eventId: Scalars['String']['input']; @@ -1718,7 +1916,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1756,6 +1954,9 @@ export type ResolversTypes = { CreateQuoteInput: ResolverTypeWrapper>; CreateReceiverInput: ResolverTypeWrapper>; CreateReceiverResponse: ResolverTypeWrapper>; + CreateTenantInput: ResolverTypeWrapper>; + CreateTenantSettingsInput: ResolverTypeWrapper>; + CreateTenantSettingsMutationResponse: ResolverTypeWrapper>; CreateWalletAddressInput: ResolverTypeWrapper>; CreateWalletAddressKeyInput: ResolverTypeWrapper>; CreateWalletAddressKeyMutationResponse: ResolverTypeWrapper>; @@ -1766,6 +1967,7 @@ export type ResolversTypes = { DeleteAssetMutationResponse: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DeleteTenantMutationResponse: ResolverTypeWrapper>; DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; @@ -1825,6 +2027,14 @@ export type ResolversTypes = { SetFeeResponse: ResolverTypeWrapper>; SortOrder: ResolverTypeWrapper>; String: ResolverTypeWrapper>; + Tenant: ResolverTypeWrapper>; + TenantEdge: ResolverTypeWrapper>; + TenantMutationResponse: ResolverTypeWrapper>; + TenantSetting: ResolverTypeWrapper>; + TenantSettingEdge: ResolverTypeWrapper>; + TenantSettingInput: ResolverTypeWrapper>; + TenantSettingsConnection: ResolverTypeWrapper>; + TenantsConnection: ResolverTypeWrapper>; TransferState: ResolverTypeWrapper>; TransferType: ResolverTypeWrapper>; TriggerWalletAddressEventsInput: ResolverTypeWrapper>; @@ -1835,6 +2045,7 @@ export type ResolversTypes = { UpdateIncomingPaymentInput: ResolverTypeWrapper>; UpdatePeerInput: ResolverTypeWrapper>; UpdatePeerMutationResponse: ResolverTypeWrapper>; + UpdateTenantInput: ResolverTypeWrapper>; UpdateWalletAddressInput: ResolverTypeWrapper>; UpdateWalletAddressMutationResponse: ResolverTypeWrapper>; VoidLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1851,6 +2062,7 @@ export type ResolversTypes = { WebhookEventFilter: ResolverTypeWrapper>; WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; + WhoamiResponse: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; }; @@ -1888,6 +2100,9 @@ export type ResolversParentTypes = { CreateQuoteInput: Partial; CreateReceiverInput: Partial; CreateReceiverResponse: Partial; + CreateTenantInput: Partial; + CreateTenantSettingsInput: Partial; + CreateTenantSettingsMutationResponse: Partial; CreateWalletAddressInput: Partial; CreateWalletAddressKeyInput: Partial; CreateWalletAddressKeyMutationResponse: Partial; @@ -1897,6 +2112,7 @@ export type ResolversParentTypes = { DeleteAssetMutationResponse: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DeleteTenantMutationResponse: Partial; DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; DepositOutgoingPaymentLiquidityInput: Partial; @@ -1949,6 +2165,14 @@ export type ResolversParentTypes = { SetFeeInput: Partial; SetFeeResponse: Partial; String: Partial; + Tenant: Partial; + TenantEdge: Partial; + TenantMutationResponse: Partial; + TenantSetting: Partial; + TenantSettingEdge: Partial; + TenantSettingInput: Partial; + TenantSettingsConnection: Partial; + TenantsConnection: Partial; TriggerWalletAddressEventsInput: Partial; TriggerWalletAddressEventsMutationResponse: Partial; UInt8: Partial; @@ -1957,6 +2181,7 @@ export type ResolversParentTypes = { UpdateIncomingPaymentInput: Partial; UpdatePeerInput: Partial; UpdatePeerMutationResponse: Partial; + UpdateTenantInput: Partial; UpdateWalletAddressInput: Partial; UpdateWalletAddressMutationResponse: Partial; VoidLiquidityWithdrawalInput: Partial; @@ -1972,6 +2197,7 @@ export type ResolversParentTypes = { WebhookEventFilter: Partial; WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; + WhoamiResponse: Partial; WithdrawEventLiquidityInput: Partial; }; @@ -2023,6 +2249,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2073,6 +2300,11 @@ export type CreateReceiverResponseResolvers; }; +export type CreateTenantSettingsMutationResponseResolvers = { + settings?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2093,6 +2325,11 @@ export type DeletePeerMutationResponseResolvers; }; +export type DeleteTenantMutationResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FeeResolvers = { assetId?: Resolver; basisPoints?: Resolver; @@ -2136,6 +2373,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2176,7 +2414,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2197,11 +2435,14 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createQuote?: Resolver>; createReceiver?: Resolver>; + createTenant?: Resolver>; + createTenantSettings?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddress?: Resolver>; createWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deleteAsset?: Resolver>; deletePeer?: Resolver>; + deleteTenant?: Resolver>; depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2213,6 +2454,7 @@ export type MutationResolvers>; updateIncomingPayment?: Resolver>; updatePeer?: Resolver>; + updateTenant?: Resolver>; updateWalletAddress?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2233,6 +2475,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2325,10 +2568,13 @@ export type QueryResolvers>; quote?: Resolver, ParentType, ContextType, RequireFields>; receiver?: Resolver, ParentType, ContextType, RequireFields>; + tenant?: Resolver>; + tenants?: Resolver>; walletAddress?: Resolver, ParentType, ContextType, RequireFields>; walletAddressByUrl?: Resolver, ParentType, ContextType, RequireFields>; walletAddresses?: Resolver>; webhookEvents?: Resolver>; + whoami?: Resolver; }; export type QuoteResolvers = { @@ -2339,6 +2585,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2383,6 +2630,54 @@ export type SetFeeResponseResolvers; }; +export type TenantResolvers = { + apiSecret?: Resolver; + createdAt?: Resolver; + deletedAt?: Resolver, ParentType, ContextType>; + email?: Resolver, ParentType, ContextType>; + id?: Resolver; + idpConsentUrl?: Resolver, ParentType, ContextType>; + idpSecret?: Resolver, ParentType, ContextType>; + publicName?: Resolver, ParentType, ContextType>; + settings?: Resolver, ParentType, ContextType, Partial>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantMutationResponseResolvers = { + tenant?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingResolvers = { + key?: Resolver; + value?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type TriggerWalletAddressEventsMutationResponseResolvers = { count?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2417,6 +2712,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; @@ -2487,6 +2783,12 @@ export type WebhookEventsEdgeResolvers; }; +export type WhoamiResponseResolvers = { + id?: Resolver; + isOperator?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AccountingTransfer?: AccountingTransferResolvers; AccountingTransferConnection?: AccountingTransferConnectionResolvers; @@ -2502,10 +2804,12 @@ export type Resolvers = { CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; + CreateTenantSettingsMutationResponse?: CreateTenantSettingsMutationResponseResolvers; CreateWalletAddressKeyMutationResponse?: CreateWalletAddressKeyMutationResponseResolvers; CreateWalletAddressMutationResponse?: CreateWalletAddressMutationResponseResolvers; DeleteAssetMutationResponse?: DeleteAssetMutationResponseResolvers; DeletePeerMutationResponse?: DeletePeerMutationResponseResolvers; + DeleteTenantMutationResponse?: DeleteTenantMutationResponseResolvers; Fee?: FeeResolvers; FeeEdge?: FeeEdgeResolvers; FeesConnection?: FeesConnectionResolvers; @@ -2539,6 +2843,13 @@ export type Resolvers = { Receiver?: ReceiverResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; + Tenant?: TenantResolvers; + TenantEdge?: TenantEdgeResolvers; + TenantMutationResponse?: TenantMutationResponseResolvers; + TenantSetting?: TenantSettingResolvers; + TenantSettingEdge?: TenantSettingEdgeResolvers; + TenantSettingsConnection?: TenantSettingsConnectionResolvers; + TenantsConnection?: TenantsConnectionResolvers; TriggerWalletAddressEventsMutationResponse?: TriggerWalletAddressEventsMutationResponseResolvers; UInt8?: GraphQLScalarType; UInt64?: GraphQLScalarType; @@ -2555,5 +2866,6 @@ export type Resolvers = { WebhookEvent?: WebhookEventResolvers; WebhookEventsConnection?: WebhookEventsConnectionResolvers; WebhookEventsEdge?: WebhookEventsEdgeResolvers; + WhoamiResponse?: WhoamiResponseResolvers; }; diff --git a/packages/auth/jest.env.js b/packages/auth/jest.env.js index 712a6a7a31..423f55578a 100644 --- a/packages/auth/jest.env.js +++ b/packages/auth/jest.env.js @@ -4,3 +4,4 @@ process.env.IDENTITY_SERVER_SECRET = '2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=' process.env.AUTH_SERVER_URL = 'http://localhost:3006' process.env.IDENTITY_SERVER_URL = 'http://localhost:3030/mock-idp/' +process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' diff --git a/packages/auth/jest.setup.js b/packages/auth/jest.setup.js index edbfb6f7ec..b232ee53ad 100644 --- a/packages/auth/jest.setup.js +++ b/packages/auth/jest.setup.js @@ -2,6 +2,7 @@ const { knex } = require('knex') // eslint-disable-next-line @typescript-eslint/no-var-requires const { GenericContainer, Wait } = require('testcontainers') +require('./jest.env') // set environment variables const POSTGRES_PORT = 5432 const REDIS_PORT = 6379 diff --git a/packages/auth/migrations/20241125233415_create_tenants_table.js b/packages/auth/migrations/20241125233415_create_tenants_table.js new file mode 100644 index 0000000000..4846a07ce9 --- /dev/null +++ b/packages/auth/migrations/20241125233415_create_tenants_table.js @@ -0,0 +1,23 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('tenants', function (table) { + table.uuid('id').notNullable().primary() + table.string('idpConsentUrl') + table.string('idpSecret') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenants') +} diff --git a/packages/auth/migrations/20241205153036_seed_operator_tenant.js b/packages/auth/migrations/20241205153036_seed_operator_tenant.js new file mode 100644 index 0000000000..a7288e1ccf --- /dev/null +++ b/packages/auth/migrations/20241205153036_seed_operator_tenant.js @@ -0,0 +1,47 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +const OPERATOR_TENANT_ID = process.env['OPERATOR_TENANT_ID'] +const IDENTITY_SERVER_URL = process.env['IDENTITY_SERVER_URL'] +const IDENTITY_SERVER_SECRET = process.env['IDENTITY_SERVER_SECRET'] + +exports.up = function (knex) { + if (!OPERATOR_TENANT_ID) { + throw new Error( + 'Could not seed operator tenant. Please configure OPERATOR_TENANT_ID environment variables' + ) + } + + const seed = { + id: OPERATOR_TENANT_ID + } + + if (IDENTITY_SERVER_URL) { + seed['idpConsentUrl'] = IDENTITY_SERVER_URL + } + + if (IDENTITY_SERVER_SECRET) { + seed['idpSecret'] = IDENTITY_SERVER_SECRET + } + + return knex.raw(` + INSERT INTO "tenants" (${Object.keys(seed) + .map((key) => `"${key}"`) + .join(', ')}) + VALUES (${Object.values(seed) + .map((key) => `'${key}'`) + .join(', ')}) + `) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.raw(` + TRUNCATE "tenants" + `) +} 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 b8bd12a938..66bc50b3a1 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -54,6 +54,7 @@ import { Redis } from 'ioredis' import { LoggingPlugin } from './graphql/plugin' import { gnapServerErrorMiddleware } from './shared/gnapErrors' import { verifyApiSignature } from './shared/utils' +import { TenantService } from './tenant/service' export interface AppContextData extends DefaultContext { logger: Logger @@ -102,6 +103,7 @@ export interface AppServices { grantRoutes: Promise interactionRoutes: Promise redis: Promise + tenantService: Promise } export type AppContainer = IocContract @@ -111,6 +113,7 @@ export class App { private interactionServer!: Server private introspectionServer!: Server private adminServer!: Server + private serviceAPIServer!: Server private logger!: Logger private config!: IAppConfig private databaseCleanupRules!: { @@ -265,7 +268,7 @@ export class App { /* Back-channel GNAP Routes */ // Grant Initiation router.post( - '/', + '/:tenantId', createValidatorMiddleware(openApi.authServerSpec, { path: '/', method: HttpMethod.POST @@ -276,7 +279,7 @@ export class App { // Grant Continue router.post( - '/continue/:id', + '/:tenantId/continue/:id', createValidatorMiddleware(openApi.authServerSpec, { path: '/continue/{id}', method: HttpMethod.POST @@ -287,7 +290,7 @@ export class App { // Grant Cancel router.delete( - '/continue/:id', + '/:tenantId/continue/:id', createValidatorMiddleware(openApi.authServerSpec, { path: '/continue/{id}', method: HttpMethod.DELETE @@ -453,6 +456,51 @@ export class App { this.interactionServer = koa.listen(port) } + public async startServiceAPIServer(port: number | string): Promise { + const koa = await this.createKoaServer() + + const router = new Router() + router.use(bodyParser()) + + const errorHandler = async (ctx: Koa.Context, next: Koa.Next) => { + try { + await next() + } catch (err) { + const logger = await ctx.container.use('logger') + logger.info( + { + method: ctx.method, + route: ctx.path, + headers: ctx.headers, + params: ctx.params, + requestBody: ctx.request.body, + err + }, + 'Service API Error' + ) + } + } + + koa.use(errorHandler) + + router.get('/healthz', (ctx: AppContext): void => { + ctx.status = 200 + }) + + const tenantRoutes = await this.container.use('tenantRoutes') + + router.get('/tenant/:id', tenantRoutes.get) + router.post('/tenant', tenantRoutes.create) + router.patch('/tenant/:id', tenantRoutes.update) + router.delete('/tenant/:id', tenantRoutes.delete) + + koa.use(cors()) + koa.use(router.middleware()) + koa.use(router.routes()) + + this.serviceAPIServer = koa.listen(port) + } + private async createKoaServer(): Promise> { const koa = new Koa({ proxy: this.config.trustProxy @@ -498,6 +546,9 @@ export class App { if (this.introspectionServer) { await this.stopServer(this.introspectionServer) } + if (this.serviceAPIServer) { + await this.stopServer(this.serviceAPIServer) + } } private async stopServer(server: Server): Promise { @@ -528,6 +579,10 @@ export class App { return this.getPort(this.introspectionServer) } + public getServiceAPIPort(): number { + return this.getPort(this.serviceAPIServer) + } + private getPort(server: Server): number { const address = server?.address() if (address && !(typeof address == 'string')) { diff --git a/packages/auth/src/config/app.ts b/packages/auth/src/config/app.ts index 9956785459..7298fc2b12 100644 --- a/packages/auth/src/config/app.ts +++ b/packages/auth/src/config/app.ts @@ -43,6 +43,7 @@ export const Config = { authPort: envInt('AUTH_PORT', 3006), interactionPort: envInt('INTERACTION_PORT', 3009), introspectionPort: envInt('INTROSPECTION_PORT', 3007), + serviceAPIPort: envInt('SERVICE_API_PORT', 3011), env: envString('NODE_ENV', 'development'), trustProxy: envBool('TRUST_PROXY', false), enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false), @@ -78,7 +79,8 @@ export const Config = { process.env.REDIS_TLS_CA_FILE_PATH, process.env.REDIS_TLS_KEY_FILE_PATH, process.env.REDIS_TLS_CERT_FILE_PATH - ) + ), + operatorTenantId: envString('OPERATOR_TENANT_ID') } function parseRedisTlsConfig( 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 2315fe083b..bb49692b73 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -21,6 +21,8 @@ import { import { createInteractionService } from './interaction/service' import { getTokenIntrospectionOpenAPI } from 'token-introspection' import { Redis } from 'ioredis' +import { createTenantService } from './tenant/service' +import { createTenantRoutes } from './tenant/routes' const container = initIocContainer(Config) const app = new App(container) @@ -137,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'), @@ -144,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') }) @@ -156,12 +169,23 @@ 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') }) } ) + container.singleton( + 'tenantRoutes', + async (deps: IocContract) => { + return createTenantRoutes({ + tenantService: await deps.use('tenantService'), + logger: await deps.use('logger') + }) + } + ) + container.singleton('openApi', async () => { const authServerSpec = await getAuthServerOpenAPI() const idpSpec = await createOpenAPI( @@ -304,6 +328,9 @@ export const start = async ( await app.startIntrospectionServer(config.introspectionPort) logger.info(`Introspection server listening on ${app.getIntrospectionPort()}`) + + await app.startServiceAPIServer(config.serviceAPIPort) + logger.info(`Service API server listening on ${app.getServiceAPIPort()}`) } // If this script is run directly, start the server 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/shared/utils.test.ts b/packages/auth/src/shared/utils.test.ts index a8eb0ca487..a611234bec 100644 --- a/packages/auth/src/shared/utils.test.ts +++ b/packages/auth/src/shared/utils.test.ts @@ -6,7 +6,7 @@ import { Config } from '../config/app' import { createContext } from '../tests/context' import { generateApiSignature } from '../tests/apiSignature' import { initIocContainer } from '..' -import { verifyApiSignature } from './utils' +import { verifyApiSignature, isValidDateString } from './utils' import { TestContainer, createTestApp } from '../tests/app' describe('utils', (): void => { @@ -150,4 +150,19 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('isValidDateString', () => { + test.each([ + ['2024-12-05T15:10:09.545Z', true], + ['2024-12-05', true], + ['invalid-date', false], // Invalid date string + ['2024-12-05T25:10:09.545Z', false], // Invalid date string (invalid hour) + ['"2024-12-05T15:10:09.545Z"', false], // Improperly formatted string + ['', false], // Empty string + [null, false], // Null value + [undefined, false] // Undefined value + ])('should return %p for input %p', (input, expected) => { + expect(isValidDateString(input!)).toBe(expected) + }) + }) }) diff --git a/packages/auth/src/shared/utils.ts b/packages/auth/src/shared/utils.ts index 4e14d070b6..358ce0c7e7 100644 --- a/packages/auth/src/shared/utils.ts +++ b/packages/auth/src/shared/utils.ts @@ -104,3 +104,8 @@ export async function verifyApiSignature( return verifyApiSignatureDigest(signature as string, ctx.request, config) } + +// Intended for Date strings like "2024-12-05T15:10:09.545Z" (e.g., from new Date().toISOString()) +export function isValidDateString(date: string): boolean { + return !isNaN(Date.parse(date)) +} 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 new file mode 100644 index 0000000000..74bbc22101 --- /dev/null +++ b/packages/auth/src/tenant/model.ts @@ -0,0 +1,21 @@ +import { BaseModel } from '../shared/baseModel' + +export class Tenant extends BaseModel { + public static get tableName(): string { + return 'tenants' + } + + public idpConsentUrl?: string + public idpSecret?: string + + 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/tenant/routes.test.ts b/packages/auth/src/tenant/routes.test.ts new file mode 100644 index 0000000000..2e3226d4e4 --- /dev/null +++ b/packages/auth/src/tenant/routes.test.ts @@ -0,0 +1,227 @@ +import { IocContract } from '@adonisjs/fold' +import { v4 } from 'uuid' + +import { createContext } from '../tests/context' +import { createTestApp, TestContainer } from '../tests/app' +import { Config } from '../config/app' +import { initIocContainer } from '..' +import { AppServices } from '../app' +import { truncateTables } from '../tests/tableManager' +import { + CreateContext, + UpdateContext, + DeleteContext, + TenantRoutes, + createTenantRoutes, + GetContext +} from './routes' +import { TenantService } from './service' +import { Tenant } from './model' + +describe('Tenant Routes', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantRoutes: TenantRoutes + let tenantService: TenantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + const logger = await deps.use('logger') + + tenantRoutes = createTenantRoutes({ + tenantService, + logger + }) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('get', (): void => { + test('Gets a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + + await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(200) + expect(ctx.body).toEqual({ + id: tenant.id, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret + }) + }) + + test('Returns 404 when getting non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + + await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + expect(ctx.body).toBeUndefined() + }) + }) + + describe('create', (): void => { + test('Creates a tenant', async (): Promise => { + const tenantData = { + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + {} + ) + ctx.request.body = tenantData + + await expect(tenantRoutes.create(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + expect(ctx.body).toBe(undefined) + + const tenant = await Tenant.query().findById(tenantData.id) + expect(tenant).toBeDefined() + expect(tenant?.idpConsentUrl).toBe(tenantData.idpConsentUrl) + expect(tenant?.idpSecret).toBe(tenantData.idpSecret) + }) + }) + + describe('update', (): void => { + test('Updates a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const updateData = { + idpConsentUrl: 'https://example.com/new-consent', + idpSecret: 'newSecret123' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + ctx.request.body = updateData + + await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + expect(ctx.body).toBe(undefined) + + const updatedTenant = await Tenant.query().findById(tenant.id) + expect(updatedTenant?.idpConsentUrl).toBe(updateData.idpConsentUrl) + expect(updatedTenant?.idpSecret).toBe(updateData.idpSecret) + }) + + test('Returns 404 when updating non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + ctx.request.body = { + idpConsentUrl: 'https://example.com/new-consent' + } + + await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + }) + }) + + describe('delete', (): void => { + test('Deletes a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + ctx.request.body = { deletedAt: new Date().toISOString() } + + await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + + const deletedTenant = await Tenant.query().findById(tenant.id) + expect(deletedTenant?.deletedAt).not.toBeNull() + }) + + test('Returns 404 when deleting non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + ctx.request.body = { deletedAt: new Date().toISOString() } + + await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + }) + }) +}) diff --git a/packages/auth/src/tenant/routes.ts b/packages/auth/src/tenant/routes.ts new file mode 100644 index 0000000000..895e07cf2c --- /dev/null +++ b/packages/auth/src/tenant/routes.ts @@ -0,0 +1,147 @@ +import { ParsedUrlQuery } from 'querystring' +import { AppContext } from '../app' +import { TenantService } from './service' +import { BaseService } from '../shared/baseService' +import { Tenant } from './model' +import { isValidDateString } from '../shared/utils' + +type TenantRequest = Exclude< + AppContext['request'], + 'body' +> & { + body: BodyT + query: ParsedUrlQuery & QueryT +} + +type TenantContext = Exclude< + AppContext, + 'request' +> & { + request: TenantRequest +} + +interface CreateTenantBody { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +type UpdateTenantBody = Partial> + +interface TenantParams { + id: string +} + +interface TenantResponse { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +export type GetContext = TenantContext +export type CreateContext = TenantContext +export type UpdateContext = TenantContext +export type DeleteContext = TenantContext<{ deletedAt: string }, TenantParams> + +export interface TenantRoutes { + get(ctx: GetContext): Promise + create(ctx: CreateContext): Promise + update(ctx: UpdateContext): Promise + delete(ctx: DeleteContext): Promise +} + +interface ServiceDependencies extends BaseService { + tenantService: TenantService +} + +export function createTenantRoutes({ + tenantService, + logger +}: ServiceDependencies): TenantRoutes { + const log = logger.child({ + service: 'TenantRoutes' + }) + + const deps = { tenantService, logger: log } + + return { + get: (ctx: GetContext) => getTenant(deps, ctx), + create: (ctx: CreateContext) => createTenant(deps, ctx), + update: (ctx: UpdateContext) => updateTenant(deps, ctx), + delete: (ctx: DeleteContext) => deleteTenant(deps, ctx) + } +} + +async function createTenant( + deps: ServiceDependencies, + ctx: CreateContext +): Promise { + const { body } = ctx.request + + await deps.tenantService.create(body) + + ctx.status = 204 +} + +async function updateTenant( + deps: ServiceDependencies, + ctx: UpdateContext +): Promise { + const { id } = ctx.params + const { body } = ctx.request + const tenant = await deps.tenantService.update(id, body) + + if (!tenant) { + ctx.status = 404 + return + } + + ctx.status = 204 +} + +async function deleteTenant( + deps: ServiceDependencies, + ctx: DeleteContext +): Promise { + const { id } = ctx.params + const { deletedAt: deletedAtString } = ctx.request.body + + if (!isValidDateString(deletedAtString)) { + ctx.status = 400 + return + } + const deletedAt = new Date(deletedAtString) + + const deleted = await deps.tenantService.delete(id, deletedAt) + + if (!deleted) { + ctx.status = 404 + return + } + + ctx.status = 204 +} + +async function getTenant( + deps: ServiceDependencies, + ctx: GetContext +): Promise { + const { id } = ctx.params + const tenant = await deps.tenantService.get(id) + + if (!tenant) { + ctx.status = 404 + return + } + + ctx.status = 200 + ctx.body = toTenantResponse(tenant) +} + +function toTenantResponse(tenant: Tenant): TenantResponse { + return { + id: tenant.id, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret + } +} diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts new file mode 100644 index 0000000000..d5b68ffc15 --- /dev/null +++ b/packages/auth/src/tenant/service.test.ts @@ -0,0 +1,181 @@ +import { faker } from '@faker-js/faker' +import { createTestApp, TestContainer } from '../tests/app' +import { truncateTables } from '../tests/tableManager' +import { Config } from '../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { TenantService } from './service' +import { Tenant } from './model' + +describe('Tenant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantService: TenantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + + tenantService = await deps.use('tenantService') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + const createTenantData = () => ({ + id: faker.string.uuid(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + }) + + describe('create', (): void => { + test('creates a tenant', async (): Promise => { + const tenantData = createTenantData() + const tenant = await tenantService.create(tenantData) + + expect(tenant).toEqual({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: undefined + }) + }) + + test('fails to create tenant with duplicate id', async (): Promise => { + const tenantData = createTenantData() + await tenantService.create(tenantData) + + await expect(tenantService.create(tenantData)).rejects.toThrow() + }) + }) + + describe('get', (): void => { + test('retrieves an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const tenant = await tenantService.get(created.id) + expect(tenant).toEqual({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) + }) + + test('returns undefined for non-existent tenant', async (): Promise => { + const tenant = await tenantService.get(faker.string.uuid()) + expect(tenant).toBeUndefined() + }) + + test('returns undefined for soft deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + await tenantService.delete(created.id, new Date()) + + const tenant = await tenantService.get(created.id) + expect(tenant).toBeUndefined() + }) + }) + + describe('update', (): void => { + test('updates an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toEqual({ + id: created.id, + ...updateData, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) + }) + + test('can update partial fields', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url() + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toEqual({ + id: created.id, + idpConsentUrl: updateData.idpConsentUrl, + idpSecret: created.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) + }) + + test('returns undefined for non-existent tenant', async (): Promise => { + const updated = await tenantService.update(faker.string.uuid(), { + idpConsentUrl: faker.internet.url() + }) + expect(updated).toBeUndefined() + }) + + test('returns undefined for soft-deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + await tenantService.delete(created.id, new Date()) + + const updated = await tenantService.update(created.id, { + idpConsentUrl: faker.internet.url() + }) + expect(updated).toBeUndefined() + }) + }) + + describe('delete', (): void => { + test('soft deletes an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const result = await tenantService.delete(created.id, new Date()) + expect(result).toBe(true) + + const tenant = await tenantService.get(created.id) + expect(tenant).toBeUndefined() + + const deletedTenant = await Tenant.query() + .findById(created.id) + .whereNotNull('deletedAt') + expect(deletedTenant).toBeDefined() + expect(deletedTenant?.deletedAt).toBeDefined() + }) + + test('returns false for non-existent tenant', async (): Promise => { + const result = await tenantService.delete(faker.string.uuid(), new Date()) + expect(result).toBe(false) + }) + + test('returns false for already deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + await tenantService.delete(created.id, new Date()) + const secondDelete = await tenantService.delete(created.id, new Date()) + expect(secondDelete).toBe(false) + }) + }) +}) diff --git a/packages/auth/src/tenant/service.ts b/packages/auth/src/tenant/service.ts new file mode 100644 index 0000000000..27e562e397 --- /dev/null +++ b/packages/auth/src/tenant/service.ts @@ -0,0 +1,83 @@ +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Tenant } from './model' + +export interface CreateOptions { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +export interface TenantService { + create(input: CreateOptions): Promise + get(id: string): Promise + update( + id: string, + input: Partial> + ): Promise + delete(id: string, deletedAt: Date): Promise +} + +interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createTenantService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'TenantService' + }) + const deps: ServiceDependencies = { + logger: log, + knex + } + + return { + create: (input: CreateOptions) => createTenant(deps, input), + get: (id: string) => getTenant(deps, id), + update: (id: string, input: Partial>) => + updateTenant(deps, id, input), + delete: (id: string, deletedAt: Date) => deleteTenant(deps, id, deletedAt) + } +} + +async function createTenant( + deps: ServiceDependencies, + input: CreateOptions +): Promise { + return await Tenant.query(deps.knex).insert(input) +} + +async function getTenant( + deps: ServiceDependencies, + id: string +): Promise { + return await Tenant.query(deps.knex) + .findById(id) + .whereNull('deletedAt') + .first() +} + +async function updateTenant( + deps: ServiceDependencies, + id: string, + input: Partial> +): Promise { + return await Tenant.query(deps.knex) + .whereNull('deletedAt') + .patchAndFetchById(id, input) +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string, + deletedAt: Date +): Promise { + const deleted = await Tenant.query(deps.knex) + .patch({ deletedAt }) + .whereNull('deletedAt') + .where('id', id) + return deleted > 0 +} diff --git a/packages/auth/src/tests/app.ts b/packages/auth/src/tests/app.ts index 87338ba17c..aedf90fad3 100644 --- a/packages/auth/src/tests/app.ts +++ b/packages/auth/src/tests/app.ts @@ -34,6 +34,7 @@ export const createTestApp = async ( config.introspectionPort = 0 config.adminPort = 0 config.interactionPort = 0 + config.serviceAPIPort = 0 const logger = createLogger({ transport: { 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/jest.config.js b/packages/backend/jest.config.js index 492a6e5e30..cdb688ff95 100644 --- a/packages/backend/jest.config.js +++ b/packages/backend/jest.config.js @@ -4,24 +4,12 @@ const baseConfig = require('../../jest.config.base.js') // eslint-disable-next-line @typescript-eslint/no-var-requires const packageName = require('./package.json').name -process.env.LOG_LEVEL = 'silent' -process.env.INSTANCE_NAME = 'Rafiki' -process.env.KEY_ID = 'myKey' -process.env.OPEN_PAYMENTS_URL = 'http://127.0.0.1:3000' -process.env.ILP_CONNECTOR_URL = 'http://127.0.0.1:3002' -process.env.ILP_ADDRESS = 'test.rafiki' -process.env.AUTH_SERVER_GRANT_URL = 'http://127.0.0.1:3006' -process.env.AUTH_SERVER_INTROSPECTION_URL = 'http://127.0.0.1:3007/' -process.env.WEBHOOK_URL = 'http://127.0.0.1:4001/webhook' -process.env.STREAM_SECRET = '2/PxuRFV9PAp0yJlnAifJ+1OxujjjI16lN+DBnLNRLA=' -process.env.USE_TIGERBEETLE = false -process.env.ENABLE_TELEMETRY = false - module.exports = { ...baseConfig, clearMocks: true, testTimeout: 30000, roots: [`/packages/${packageName}`], + setupFiles: [`/packages/${packageName}/jest.env.js`], globalSetup: `/packages/${packageName}/jest.setup.ts`, globalTeardown: `/packages/${packageName}/jest.teardown.js`, testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js new file mode 100644 index 0000000000..4a8435dd72 --- /dev/null +++ b/packages/backend/jest.env.js @@ -0,0 +1,17 @@ +process.env.LOG_LEVEL = 'silent' +process.env.INSTANCE_NAME = 'Rafiki' +process.env.KEY_ID = 'myKey' +process.env.OPEN_PAYMENTS_URL = 'http://127.0.0.1:3000' +process.env.ILP_CONNECTOR_URL = 'http://127.0.0.1:3002' +process.env.ILP_ADDRESS = 'test.rafiki' +process.env.AUTH_SERVER_GRANT_URL = 'http://127.0.0.1:3006' +process.env.AUTH_SERVER_INTROSPECTION_URL = 'http://127.0.0.1:3007/' +process.env.AUTH_SERVICE_API_URL = 'http://127.0.0.1:3011' +process.env.WEBHOOK_URL = 'http://127.0.0.1:4001/webhook' +process.env.STREAM_SECRET = '2/PxuRFV9PAp0yJlnAifJ+1OxujjjI16lN+DBnLNRLA=' +process.env.USE_TIGERBEETLE = false +process.env.ENABLE_TELEMETRY = false +process.env.AUTH_ADMIN_API_URL = 'http://127.0.0.1:3003/graphql' +process.env.AUTH_ADMIN_API_SECRET = 'test-secret' +process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' +process.env.API_SECRET = 'KQEXlZO65jUJXakXnLxGO7dk387mt71G9tZ42rULSNU=' diff --git a/packages/backend/jest.setup.ts b/packages/backend/jest.setup.ts index ff90fa720f..ef4340581d 100644 --- a/packages/backend/jest.setup.ts +++ b/packages/backend/jest.setup.ts @@ -1,5 +1,6 @@ import { knex } from 'knex' import { GenericContainer, Wait } from 'testcontainers' +require('./jest.env') // set environment variables const POSTGRES_PORT = 5432 const REDIS_PORT = 6379 diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js new file mode 100644 index 0000000000..e6fc77e934 --- /dev/null +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -0,0 +1,26 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('tenants', function (table) { + table.uuid('id').notNullable().primary() + table.string('email') + table.string('apiSecret').notNullable() + table.string('idpConsentUrl') + table.string('idpSecret') + table.string('publicName') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenants') +} diff --git a/packages/backend/migrations/20241205153035_seed_operator_tenant.js b/packages/backend/migrations/20241205153035_seed_operator_tenant.js new file mode 100644 index 0000000000..6af21658c1 --- /dev/null +++ b/packages/backend/migrations/20241205153035_seed_operator_tenant.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +const OPERATOR_TENANT_ID = process.env['OPERATOR_TENANT_ID'] +const OPERATOR_API_SECRET = process.env['API_SECRET'] + +exports.up = function (knex) { + if (!OPERATOR_TENANT_ID || !OPERATOR_API_SECRET) { + throw new Error( + 'Could not seed operator tenant. Please configure OPERATOR_TENANT_ID and API_SECRET environment variables' + ) + } + + return knex.raw(` + INSERT INTO "tenants" ("id", "apiSecret") + VALUES ('${OPERATOR_TENANT_ID}', '${OPERATOR_API_SECRET}') + `) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.raw(` + TRUNCATE "tenants" + `) +} 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/20241216160130_backfill_tenant_on_assets.js b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js new file mode 100644 index 0000000000..85afdf458e --- /dev/null +++ b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('assets', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "assets" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('assets', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('assets', (table) => { + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/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/20250117112902_add_tenant_to_wallet_address.js b/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js new file mode 100644 index 0000000000..a3f21e9904 --- /dev/null +++ b/packages/backend/migrations/20250117112902_add_tenant_to_wallet_address.js @@ -0,0 +1,32 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('walletAddresses', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "walletAddresses" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('walletAddresses', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return Promise.all([ + knex.schema.alterTable('walletAddresses', function (table) { + table.dropColumn('tenantId') + }) + ]) +} diff --git a/packages/backend/migrations/20250117205655_create_tenant_settings_table.js b/packages/backend/migrations/20250117205655_create_tenant_settings_table.js new file mode 100644 index 0000000000..649e811da5 --- /dev/null +++ b/packages/backend/migrations/20250117205655_create_tenant_settings_table.js @@ -0,0 +1,22 @@ +exports.up = function (knex) { + return knex.schema.createTable('tenantSettings', function (table) { + table.uuid('id').notNullable().primary() + table.string('key').notNullable().index() + table.string('value').notNullable() + + table + .uuid('tenantId') + .notNullable() + .references('tenants.id') + .index() + .onDelete('CASCADE') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenantSettings') +} 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/migrations/20250301103930_rename_wallet_address_url_to_address.js b/packages/backend/migrations/20250301103930_rename_wallet_address_url_to_address.js new file mode 100644 index 0000000000..b5bdee041d --- /dev/null +++ b/packages/backend/migrations/20250301103930_rename_wallet_address_url_to_address.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('walletAddresses', (table) => { + table.renameColumn('url', 'address') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('walletAddresses', (table) => { + table.renameColumn('address', 'url') + }) +} diff --git a/packages/backend/migrations/20250301203110_unique_tenant_settings_key.js b/packages/backend/migrations/20250301203110_unique_tenant_settings_key.js new file mode 100644 index 0000000000..41d3ce1402 --- /dev/null +++ b/packages/backend/migrations/20250301203110_unique_tenant_settings_key.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.alterTable('tenantSettings', function (table) { + table.unique(['tenantId', 'key']) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('tenantSettings', function (table) { + table.dropUnique(['tenantId', 'key']) + }) +} diff --git a/packages/backend/package.json b/packages/backend/package.json index efabe8becd..1600dfd657 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,7 +16,6 @@ "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only --require ./src/telemetry/index.ts src/index.ts" }, "devDependencies": { - "@apollo/client": "^3.11.8", "@graphql-codegen/cli": "5.0.2", "@graphql-codegen/introspection": "4.0.3", "@graphql-codegen/typescript": "4.0.6", @@ -46,6 +45,7 @@ }, "dependencies": { "@adonisjs/fold": "^8.2.0", + "@apollo/client": "^3.11.8", "@apollo/server": "^4.11.2", "@as-integrations/koa": "^1.1.1", "@escape.tech/graphql-armor": "^2.4.0", diff --git a/packages/backend/src/accounting/psql/balance.test.ts b/packages/backend/src/accounting/psql/balance.test.ts index 559bbb229c..027c6f1eff 100644 --- a/packages/backend/src/accounting/psql/balance.test.ts +++ b/packages/backend/src/accounting/psql/balance.test.ts @@ -4,7 +4,7 @@ import { createTestApp, TestContainer } from '../../tests/app' import { Config } from '../../config/app' import { initIocContainer } from '../../' import { Asset } from '../../asset/model' -import { randomAsset } from '../../tests/asset' +import { createAsset } from '../../tests/asset' import { truncateTables } from '../../tests/tableManager' import { LedgerAccount } from './ledger-account/model' import { createLedgerAccount } from '../../tests/ledgerAccount' @@ -12,15 +12,18 @@ import { getAccountBalances } from './balance' import { ServiceDependencies } from './service' import { LedgerTransferState } from '../service' import { createLedgerTransfer } from '../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' describe('Balances', (): void => { + let deps: IocContract let serviceDeps: ServiceDependencies let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) serviceDeps = { logger: await deps.use('logger'), @@ -31,7 +34,7 @@ describe('Balances', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await createAsset(deps) }) afterEach(async (): Promise => { @@ -48,7 +51,7 @@ describe('Balances', (): void => { let peerAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await createAsset(deps) ;[account, peerAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex) diff --git a/packages/backend/src/accounting/psql/ledger-account/index.test.ts b/packages/backend/src/accounting/psql/ledger-account/index.test.ts index 4d5b0140ec..fdb9a898c2 100644 --- a/packages/backend/src/accounting/psql/ledger-account/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-account/index.test.ts @@ -32,7 +32,10 @@ describe('Ledger Account', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { diff --git a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts index 4a75ce60cc..3efb12f752 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts @@ -45,7 +45,10 @@ describe('Ledger Transfer', (): void => { let settlementAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[account, peerAccount, settlementAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex), diff --git a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts index 63dbcd8cef..aea03c66f5 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts @@ -9,14 +9,17 @@ import { LedgerAccount, LedgerAccountType } from '../ledger-account/model' import { createLedgerAccount } from '../../../tests/ledgerAccount' import { LedgerTransferState } from '../../service' import { createLedgerTransfer } from '../../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../../app' describe('Ledger Transfer Model', (): void => { + let deps: IocContract let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) knex = appContainer.knex }) @@ -25,7 +28,10 @@ describe('Ledger Transfer Model', (): void => { let debitAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[creditAccount, debitAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount( diff --git a/packages/backend/src/accounting/psql/service.test.ts b/packages/backend/src/accounting/psql/service.test.ts index 316cdf811e..e799068137 100644 --- a/packages/backend/src/accounting/psql/service.test.ts +++ b/packages/backend/src/accounting/psql/service.test.ts @@ -54,7 +54,10 @@ describe('Psql Accounting Service', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { @@ -892,7 +895,10 @@ describe('Psql Accounting Service', (): void => { const timeout = 10 // 10 seconds beforeEach(async (): Promise => { - const sourceAsset = await assetService.create(randomAsset()) + const sourceAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(sourceAsset)) sourceAccount = await accountFactory.build({ @@ -902,7 +908,10 @@ describe('Psql Accounting Service', (): void => { const destinationAsset = sameAsset ? sourceAsset - : await assetService.create(randomAsset()) + : await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(destinationAsset)) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index dcd3ff0e3e..7a6c6fa1ac 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -70,7 +70,8 @@ import { applyMiddleware } from 'graphql-middleware' import { Redis } from 'ioredis' import { idempotencyGraphQLMiddleware, - lockGraphQLMutationMiddleware + lockGraphQLMutationMiddleware, + setForTenantIdGraphQLMutationMiddleware } from './graphql/middleware' import { createRedisDataStore } from './middleware/cache/data-stores/redis' import { createRedisLock } from './middleware/lock/redis' @@ -85,7 +86,6 @@ import { IlpPaymentService } from './payment-method/ilp/service' import { TelemetryService } from './telemetry/service' import { ApolloArmor } from '@escape.tech/graphql-armor' import { openPaymentsServerErrorMiddleware } from './open_payments/route-errors' -import { verifyApiSignature } from './shared/utils' import { WalletAddress } from './open_payments/wallet_address/model' import { getWalletAddressUrlFromIncomingPayment, @@ -101,6 +101,15 @@ import { LoggingPlugin } from './graphql/plugin' import { LocalPaymentService } from './payment-method/local/service' import { GrantService } from './open_payments/grant/service' import { AuthServerService } from './open_payments/authServer/service' +import { Tenant } from './tenants/model' +import { + getTenantFromApiSignature, + TenantApiSignatureResult +} from './shared/utils' +import { TenantService } from './tenants/service' +import { AuthServiceClient } from './auth-service-client/client' +import { TenantSettingService } from './tenants/settings/service' + export interface AppContextData { logger: Logger container: AppContainer @@ -214,6 +223,15 @@ type ContextType = T extends ( const WALLET_ADDRESS_PATH = '/:walletAddressPath+' +export interface TenantedApolloContext extends ApolloContext { + tenant: Tenant + isOperator: boolean +} + +export interface ForTenantIdContext extends TenantedApolloContext { + forTenantId?: string +} + export interface AppServices { logger: Promise telemetry: Promise @@ -256,6 +274,9 @@ export interface AppServices { paymentMethodHandlerService: Promise ilpPaymentService: Promise localPaymentService: Promise + tenantService: Promise + authServiceClient: AuthServiceClient + tenantSettingService: Promise } export type AppContainer = IocContract @@ -324,7 +345,8 @@ export class App { ), idempotencyGraphQLMiddleware( createRedisDataStore(redis, this.config.graphQLIdempotencyKeyTtlMs) - ) + ), + setForTenantIdGraphQLMutationMiddleware() ) // Setup Armor @@ -385,19 +407,58 @@ export class App { } ) - if (this.config.adminApiSecret) { - koa.use(async (ctx, next: Koa.Next): Promise => { - if (!(await verifyApiSignature(ctx, this.config))) { + let tenantApiSignatureResult: TenantApiSignatureResult + const tenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + const result = await getTenantFromApiSignature(ctx, this.config) + if (!result) { + ctx.throw(401, 'Unauthorized') + } else { + tenantApiSignatureResult = { + tenant: result.tenant, + isOperator: result.isOperator ? true : false + } + } + return next() + } + + const testTenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + if (ctx.headers['tenant-id']) { + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.get( + ctx.headers['tenant-id'] as string + ) + + if (tenant) { + tenantApiSignatureResult = { + tenant, + isOperator: tenant.apiSecret === this.config.adminApiSecret + } + } else { ctx.throw(401, 'Unauthorized') } - return next() - }) + } + return next() } + // For tests, we still need to get the tenant in the middleware, but + // we don't need to verify the signature nor prevent replay attacks + koa.use( + this.config.env !== 'test' + ? tenantSignatureMiddleware + : testTenantSignatureMiddleware + ) + koa.use( koaMiddleware(this.apolloServer, { - context: async (): Promise => { + context: async (): Promise => { return { + ...tenantApiSignatureResult, container: this.container, logger: await this.container.use('logger') } @@ -440,7 +501,7 @@ export class App { // POST /incoming-payments // Create incoming payment router.post>( - '/incoming-payments', + '/:tenantId/incoming-payments', createValidatorMiddleware< ContextType> >( @@ -467,7 +528,7 @@ export class App { DefaultState, SignedCollectionContext >( - '/incoming-payments', + '/:tenantId/incoming-payments', createValidatorMiddleware< ContextType> >( @@ -491,7 +552,7 @@ export class App { // POST /outgoing-payment // Create outgoing payment router.post>( - '/outgoing-payments', + '/:tenantId/outgoing-payments', createValidatorMiddleware< ContextType> >( @@ -518,7 +579,7 @@ export class App { DefaultState, SignedCollectionContext >( - '/outgoing-payments', + '/:tenantId/outgoing-payments', createValidatorMiddleware< ContextType> >( @@ -542,7 +603,7 @@ export class App { // POST /quotes // Create quote router.post>( - '/quotes', + '/:tenantId/quotes', createValidatorMiddleware< ContextType> >( @@ -566,7 +627,7 @@ export class App { // GET /incoming-payments/{id} // Read incoming payment router.get( - '/incoming-payments/:id', + '/:tenantId/incoming-payments/:id', createValidatorMiddleware< ContextType >( @@ -591,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, { @@ -613,7 +674,7 @@ export class App { // GET /outgoing-payments/{id} // Read outgoing payment router.get( - '/outgoing-payments/:id', + '/:tenantId/outgoing-payments/:id', createValidatorMiddleware>( resourceServerSpec, { @@ -635,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/asset/model.test.ts b/packages/backend/src/asset/model.test.ts index 461fb043e5..d9464d7844 100644 --- a/packages/backend/src/asset/model.test.ts +++ b/packages/backend/src/asset/model.test.ts @@ -38,6 +38,7 @@ describe('Models', (): void => { beforeEach(async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, liquidityThreshold: BigInt(100) } const assetOrError = await assetService.create(options) diff --git a/packages/backend/src/asset/model.ts b/packages/backend/src/asset/model.ts index 62237fcd20..1dda49c754 100644 --- a/packages/backend/src/asset/model.ts +++ b/packages/backend/src/asset/model.ts @@ -13,6 +13,7 @@ export class Asset extends BaseModel implements LiquidityAccount { // TigerBeetle account 2 byte ledger field representing account's asset public readonly ledger!: number + public readonly tenantId!: string public readonly withdrawalThreshold!: bigint | null diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 6c05b221a2..5fd3042012 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -57,6 +57,7 @@ describe('Asset Service', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -80,7 +81,10 @@ describe('Asset Service', (): void => { 'createLiquidityAndLinkedSettlementAccount' ) - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) expect(liquidityAndSettlementSpy).toHaveBeenCalledWith( @@ -100,6 +104,7 @@ describe('Asset Service', (): void => { test('Asset can be created with minimum account withdrawal amount', async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10) } const asset = await assetService.create(options) @@ -113,7 +118,7 @@ describe('Asset Service', (): void => { }) test('Cannot create duplicate asset', async (): Promise => { - const options = randomAsset() + const options = { ...randomAsset(), tenantId: Config.operatorTenantId } await expect(assetService.create(options)).resolves.toMatchObject(options) await expect(assetService.create(options)).resolves.toEqual( AssetError.DuplicateAsset @@ -123,7 +128,8 @@ describe('Asset Service', (): void => { test('Cannot create asset with scale > 255', async (): Promise => { const options = { code: 'ABC', - scale: 256 + scale: 256, + tenantId: Config.operatorTenantId } await expect(assetService.create(options)).rejects.toThrow( CheckViolationError @@ -133,7 +139,10 @@ describe('Asset Service', (): void => { describe('get', (): void => { test('Can get asset by id', async (): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) await expect(assetService.get(asset.id)).resolves.toEqual(asset) }) @@ -161,6 +170,7 @@ describe('Asset Service', (): void => { beforeEach(async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -186,6 +196,7 @@ describe('Asset Service', (): void => { }): Promise => { const asset = await assetService.update({ id: assetId, + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -198,10 +209,29 @@ describe('Asset Service', (): void => { } ) + test('Cannot update asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.update({ + id: asset.id, + tenantId: uuid(), + withdrawalThreshold: BigInt(10), + liquidityThreshold: null + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) + test('Cannot update unknown asset', async (): Promise => { await expect( assetService.update({ id: uuid(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: null }) @@ -213,7 +243,11 @@ describe('Asset Service', (): void => { getPageTests({ createModel: () => createAsset(deps), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder) + assetService.getPage({ + pagination, + sortOrder, + tenantId: Config.operatorTenantId + }) }) }) @@ -221,7 +255,10 @@ describe('Asset Service', (): void => { test('returns all assets', async (): Promise => { const assets: (Asset | AssetError)[] = [] for (let i = 0; i < 3; i++) { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assets.push(asset) } @@ -235,12 +272,16 @@ describe('Asset Service', (): void => { describe('delete', (): void => { test('Can delete asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -248,18 +289,26 @@ describe('Asset Service', (): void => { }) test('Can delete and restore asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const { code, scale } = newAsset const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) - const restoredAsset = await assetService.create({ code, scale }) + const restoredAsset = await assetService.create({ + code, + scale, + tenantId: newAsset.tenantId + }) assert.ok(!isAssetError(restoredAsset)) expect(restoredAsset.id).toEqual(newAssetId) expect(restoredAsset.code).toEqual(code) @@ -268,24 +317,35 @@ describe('Asset Service', (): void => { }) test('Cannot delete in use asset (wallet)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id // make sure there is at least 1 wallet address using asset const walletAddress = walletAddressService.create({ url: 'https://alice.me/.well-known/pay', + tenantId: Config.operatorTenantId, assetId: newAssetId }) assert.ok(!isWalletAddressError(walletAddress)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) test('Cannot delete in use asset (peer)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id @@ -310,9 +370,30 @@ describe('Asset Service', (): void => { assert.ok(!isPeerError(peer)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) + + test('Cannot delete asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.delete({ + id: asset.id, + tenantId: uuid(), + deletedAt: new Date() + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) }) }) @@ -352,6 +433,7 @@ describe('Asset Service using Cache', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -380,6 +462,7 @@ describe('Asset Service using Cache', (): void => { const spyCacheUpdateSet = jest.spyOn(assetCache, 'set') const assetUpdate = await assetService.update({ id: asset.id, + tenantId: asset.tenantId, withdrawalThreshold, liquidityThreshold }) @@ -400,6 +483,7 @@ describe('Asset Service using Cache', (): void => { // Delete the asset, and ensure it is not cached: const deletedAsset = await assetService.delete({ id: asset.id, + tenantId: asset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -409,4 +493,26 @@ describe('Asset Service using Cache', (): void => { } ) }) + + test('cannot get asset from cache if incorrect tenantId', async (): Promise => { + const options = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + const spyCacheSet = jest.spyOn(assetCache, 'set') + + const asset = await assetService.create(options) + assert.ok(!isAssetError(asset)) + + expect(spyCacheSet).toHaveBeenCalledWith( + asset.id, + expect.objectContaining(options) + ) + + const spyCacheGet = jest.spyOn(assetCache, 'get') + await expect(assetService.get(asset.id, uuid())).resolves.toEqual(undefined) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(asset.id) + }) }) diff --git a/packages/backend/src/asset/service.ts b/packages/backend/src/asset/service.ts index 5dbe8b63c1..9d68d1f4f7 100644 --- a/packages/backend/src/asset/service.ts +++ b/packages/backend/src/asset/service.ts @@ -15,27 +15,43 @@ export interface AssetOptions { } export interface CreateOptions extends AssetOptions { + tenantId: string withdrawalThreshold?: bigint liquidityThreshold?: bigint } export interface UpdateOptions { id: string + tenantId: string withdrawalThreshold: bigint | null liquidityThreshold: bigint | null } + export interface DeleteOptions { id: string + tenantId: string deletedAt: Date } +interface GetByCodeAndScaleOptions { + code: string + scale: number + tenantId: string +} + +interface GetPageOptions { + pagination?: Pagination + sortOrder?: SortOrder + tenantId?: string +} + export interface AssetService { create(options: CreateOptions): Promise update(options: UpdateOptions): Promise delete(options: DeleteOptions): Promise - get(id: string): Promise - getByCodeAndScale(code: string, scale: number): Promise - getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise + get(id: string, tenantId?: string): Promise + getByCodeAndScale(options: GetByCodeAndScaleOptions): Promise + getPage(options: GetPageOptions): Promise getAll(): Promise } @@ -65,18 +81,22 @@ export async function createAssetService({ create: (options) => createAsset(deps, options), update: (options) => updateAsset(deps, options), delete: (options) => deleteAsset(deps, options), - get: (id) => getAsset(deps, id), - getByCodeAndScale: (code, scale) => - getAssetByCodeAndScale(deps, code, scale), - getPage: (pagination?, sortOrder?) => - getAssetsPage(deps, pagination, sortOrder), + get: (id, tenantId) => getAsset(deps, id, tenantId), + getByCodeAndScale: (options) => getAssetByCodeAndScale(deps, options), + getPage: (options) => getAssetsPage(deps, options), getAll: () => getAll(deps) } } async function createAsset( deps: ServiceDependencies, - { code, scale, withdrawalThreshold, liquidityThreshold }: CreateOptions + { + code, + scale, + withdrawalThreshold, + liquidityThreshold, + tenantId + }: CreateOptions ): Promise { try { // check if exists but deleted | by code-scale @@ -84,6 +104,7 @@ async function createAsset( .whereNotNull('deletedAt') .where('code', code) .andWhere('scale', scale) + .andWhere('tenantId', tenantId) .first() if (deletedAsset) { @@ -105,6 +126,7 @@ async function createAsset( const asset = await Asset.query(trx).insertAndFetch({ code, scale, + tenantId, withdrawalThreshold, liquidityThreshold }) @@ -126,14 +148,18 @@ async function createAsset( async function updateAsset( deps: ServiceDependencies, - { id, withdrawalThreshold, liquidityThreshold }: UpdateOptions + { id, tenantId, withdrawalThreshold, liquidityThreshold }: UpdateOptions ): Promise { if (!deps.knex) { throw new Error('Knex undefined') } try { const asset = await Asset.query(deps.knex) - .patchAndFetchById(id, { withdrawalThreshold, liquidityThreshold }) + .where({ tenantId }) + .patchAndFetchById(id, { + withdrawalThreshold, + liquidityThreshold + }) .throwIfNotFound() await deps.assetCache.set(id, asset) @@ -149,12 +175,20 @@ async function updateAsset( // soft delete async function deleteAsset( deps: ServiceDependencies, - { id, deletedAt }: DeleteOptions + options: DeleteOptions ): Promise { + const { id, tenantId, deletedAt } = options if (!deps.knex) { throw new Error('Knex undefined') } + // Check the correct tenant is requesting delete operation + const existingAsset = await getAsset(deps, id, tenantId) + + if (!existingAsset) { + return AssetError.UnknownAsset + } + await deps.assetCache.delete(id) try { // return error in case there is a peer or wallet address using the asset @@ -182,12 +216,22 @@ async function deleteAsset( async function getAsset( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { const inMem = await deps.assetCache.get(id) - if (inMem) return inMem + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + const asset = await query.findById(id) - const asset = await Asset.query(deps.knex).whereNull('deletedAt').findById(id) if (asset) await deps.assetCache.set(asset.id, asset) return asset @@ -195,24 +239,27 @@ async function getAsset( async function getAssetByCodeAndScale( deps: ServiceDependencies, - code: string, - scale: number + options: GetByCodeAndScaleOptions ): Promise { - return await Asset.query(deps.knex) - .where({ code: code, scale: scale }) - .first() + return await Asset.query(deps.knex).where(options).first() } async function getAssetsPage( deps: ServiceDependencies, - pagination?: Pagination, - sortOrder?: SortOrder + options: GetPageOptions ): Promise { - return await Asset.query(deps.knex) - .whereNull('deletedAt') - .getPage(pagination, sortOrder) + const { tenantId, pagination, sortOrder } = options + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + return await query.getPage(pagination, sortOrder) } +// This used in auto-peering, what to do? async function getAll(deps: ServiceDependencies): Promise { return await Asset.query(deps.knex).whereNull('deletedAt') } diff --git a/packages/backend/src/auth-service-client/client.test.ts b/packages/backend/src/auth-service-client/client.test.ts new file mode 100644 index 0000000000..5a4af980c0 --- /dev/null +++ b/packages/backend/src/auth-service-client/client.test.ts @@ -0,0 +1,123 @@ +import { faker } from '@faker-js/faker' +import nock from 'nock' +import { AuthServiceClient, AuthServiceClientError } from './client' + +describe('AuthServiceClient', () => { + const baseUrl = 'http://auth-service.biz' + let client: AuthServiceClient + + beforeEach(() => { + client = new AuthServiceClient(baseUrl) + nock.cleanAll() + }) + + afterEach(() => { + expect(nock.isDone()).toBeTruthy() + }) + + const createTenantData = () => ({ + id: faker.string.uuid(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + }) + + describe('tenant', () => { + describe('get', () => { + test('retrieves a tenant', async () => { + const tenantData = createTenantData() + + nock(baseUrl).get(`/tenant/${tenantData.id}`).reply(200, tenantData) + + const tenant = await client.tenant.get(tenantData.id) + expect(tenant).toEqual(tenantData) + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + + nock(baseUrl).get(`/tenant/${id}`).reply(404) + + await expect(client.tenant.get(id)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('create', () => { + test('creates a new tenant', async () => { + const tenantData = createTenantData() + + nock(baseUrl).post('/tenant', tenantData).reply(204) + + await expect(client.tenant.create(tenantData)).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const tenantData = createTenantData() + + nock(baseUrl) + .post('/tenant', tenantData) + .reply(409, { message: 'Tenant already exists' }) + + await expect(client.tenant.create(tenantData)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('update', () => { + test('updates an existing tenant', async () => { + const id = faker.string.uuid() + const updateData = { + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + } + + nock(baseUrl).patch(`/tenant/${id}`, updateData).reply(204) + + await expect( + client.tenant.update(id, updateData) + ).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + const updateData = { + idpConsentUrl: faker.internet.url() + } + + nock(baseUrl) + .patch(`/tenant/${id}`, updateData) + .reply(404, { message: 'Tenant not found' }) + + await expect(client.tenant.update(id, updateData)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('delete', () => { + test('deletes an existing tenant', async () => { + const id = faker.string.uuid() + + nock(baseUrl).delete(`/tenant/${id}`).reply(204) + + await expect( + client.tenant.delete(id, new Date()) + ).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + + nock(baseUrl) + .delete(`/tenant/${id}`) + .reply(404, { message: 'Tenant not found' }) + + await expect(client.tenant.delete(id, new Date())).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + }) +}) diff --git a/packages/backend/src/auth-service-client/client.ts b/packages/backend/src/auth-service-client/client.ts new file mode 100644 index 0000000000..402435446f --- /dev/null +++ b/packages/backend/src/auth-service-client/client.ts @@ -0,0 +1,92 @@ +interface Tenant { + id: string + idpConsentUrl?: string + idpSecret?: string +} + +export class AuthServiceClientError extends Error { + constructor( + message: string, + public status: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public details?: any + ) { + super(message) + this.status = status + this.details = details + } +} + +export class AuthServiceClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async request(path: string, options: RequestInit): Promise { + options.headers = { 'Content-Type': 'application/json', ...options.headers } + + const response = await fetch(`${this.baseUrl}${path}`, options) + + if (!response.ok) { + let errorDetails + try { + errorDetails = await response.json() + } catch { + errorDetails = { message: response.statusText } + } + + throw new AuthServiceClientError( + `Auth Service Client Error: ${response.status} ${response.statusText}`, + response.status, + errorDetails + ) + } + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + return undefined as T + } + + const contentType = response.headers.get('Content-Type') + if (contentType && contentType.includes('application/json')) { + try { + return (await response.json()) as T + } catch (error) { + throw new AuthServiceClientError( + `Failed to parse JSON response from ${path}`, + response.status + ) + } + } + + return (await response.text()) as T + } + + public tenant = { + get: (id: string) => + this.request(`/tenant/${id}`, { method: 'GET' }), + create: (data: Tenant) => + this.request('/tenant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }), + update: (id: string, data: Partial>) => + this.request(`/tenant/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }), + delete: (id: string, deletedAt: Date) => + this.request(`/tenant/${id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deletedAt }) + }) + } +} diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 26e6dcdc13..0834b5713d 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -126,6 +126,10 @@ export const Config = { authServerGrantUrl: envString('AUTH_SERVER_GRANT_URL'), authServerIntrospectionUrl: envString('AUTH_SERVER_INTROSPECTION_URL'), + authAdminApiUrl: envString('AUTH_ADMIN_API_URL'), + authAdminApiSecret: envString('AUTH_ADMIN_API_SECRET'), + authAdminApiSignatureVersion: envInt('AUTH_ADMIN_API_SIGNATURE_VERSION', 1), + authServiceApiUrl: envString('AUTH_SERVICE_API_URL'), outgoingPaymentWorkers: envInt('OUTGOING_PAYMENT_WORKERS', 1), outgoingPaymentWorkerIdle: envInt('OUTGOING_PAYMENT_WORKER_IDLE', 10), // milliseconds @@ -159,7 +163,7 @@ export const Config = { signatureSecret: process.env.SIGNATURE_SECRET, // optional signatureVersion: envInt('SIGNATURE_VERSION', 1), - adminApiSecret: process.env.API_SECRET, // optional + adminApiSecret: envString('API_SECRET'), adminApiSignatureVersion: envInt('API_SIGNATURE_VERSION', 1), adminApiSignatureTtlSeconds: envInt('ADMIN_API_SIGNATURE_TTL_SECONDS', 30), @@ -192,7 +196,9 @@ export const Config = { 'MAX_OUTGOING_PAYMENT_RETRY_ATTEMPTS', 5 ), - localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000) + localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000), + operatorTenantId: envString('OPERATOR_TENANT_ID'), + dbSchema: undefined as string | undefined } function parseRedisTlsConfig( diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index 1745966aa2..98df5f84b0 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -594,7 +594,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** fees after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** fees after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -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.", @@ -2105,6 +2133,171 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateTenantInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "apiSecret", + "description": "Secret used to secure requests made for this tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": "Contact email of the tenant owner.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpConsentUrl", + "description": "URL of the tenant's identity provider's consent screen.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpSecret", + "description": "Secret used to secure requests from the tenant's identity provider.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicName", + "description": "Public name for the tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settings", + "description": "Initial settings for tenant.", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TenantSettingInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateTenantSettingsInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "settings", + "description": "List of a settings for a tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TenantSettingInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreateTenantSettingsMutationResponse", + "description": null, + "fields": [ + { + "name": "settings", + "description": "New tenant settings.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantSetting", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreateWalletAddressInput", @@ -2171,6 +2364,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature.", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "url", "description": "Wallet address URL. This cannot be changed.", @@ -2513,6 +2718,33 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "DeleteTenantMutationResponse", + "description": null, + "fields": [ + { + "name": "success", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "DepositAssetLiquidityInput", @@ -3397,6 +3629,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.", @@ -3988,6 +4232,11 @@ "name": "Peer", "ofType": null }, + { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + }, { "kind": "OBJECT", "name": "WalletAddress", @@ -4490,8 +4739,8 @@ "deprecationReason": null }, { - "name": "createWalletAddress", - "description": "Create a new wallet address.", + "name": "createTenant", + "description": "As an operator, create a tenant.", "args": [ { "name": "input", @@ -4501,7 +4750,7 @@ "name": null, "ofType": { "kind": "INPUT_OBJECT", - "name": "CreateWalletAddressInput", + "name": "CreateTenantInput", "ofType": null } }, @@ -4515,7 +4764,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "CreateWalletAddressMutationResponse", + "name": "TenantMutationResponse", "ofType": null } }, @@ -4523,8 +4772,8 @@ "deprecationReason": null }, { - "name": "createWalletAddressKey", - "description": "Add a public key to a wallet address that is used to verify Open Payments requests.", + "name": "createTenantSettings", + "description": null, "args": [ { "name": "input", @@ -4534,7 +4783,7 @@ "name": null, "ofType": { "kind": "INPUT_OBJECT", - "name": "CreateWalletAddressKeyInput", + "name": "CreateTenantSettingsInput", "ofType": null } }, @@ -4545,15 +4794,15 @@ ], "type": { "kind": "OBJECT", - "name": "CreateWalletAddressKeyMutationResponse", + "name": "CreateTenantSettingsMutationResponse", "ofType": null }, "isDeprecated": false, "deprecationReason": null }, { - "name": "createWalletAddressWithdrawal", - "description": "Withdraw liquidity from a wallet address received via Web Monetization.", + "name": "createWalletAddress", + "description": "Create a new wallet address.", "args": [ { "name": "input", @@ -4563,7 +4812,7 @@ "name": null, "ofType": { "kind": "INPUT_OBJECT", - "name": "CreateWalletAddressWithdrawalInput", + "name": "CreateWalletAddressInput", "ofType": null } }, @@ -4573,16 +4822,20 @@ } ], "type": { - "kind": "OBJECT", - "name": "WalletAddressWithdrawalMutationResponse", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreateWalletAddressMutationResponse", + "ofType": null + } }, "isDeprecated": false, "deprecationReason": null }, { - "name": "deleteAsset", - "description": "Delete an asset.", + "name": "createWalletAddressKey", + "description": "Add a public key to a wallet address that is used to verify Open Payments requests.", "args": [ { "name": "input", @@ -4592,7 +4845,7 @@ "name": null, "ofType": { "kind": "INPUT_OBJECT", - "name": "DeleteAssetInput", + "name": "CreateWalletAddressKeyInput", "ofType": null } }, @@ -4602,14 +4855,72 @@ } ], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "DeleteAssetMutationResponse", - "ofType": null - } - }, + "kind": "OBJECT", + "name": "CreateWalletAddressKeyMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createWalletAddressWithdrawal", + "description": "Withdraw liquidity from a wallet address received via Web Monetization.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateWalletAddressWithdrawalInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WalletAddressWithdrawalMutationResponse", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deleteAsset", + "description": "Delete an asset.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DeleteAssetInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeleteAssetMutationResponse", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null }, @@ -4646,6 +4957,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "deleteTenant", + "description": "Delete a tenant.", + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DeleteTenantMutationResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "depositAssetLiquidity", "description": "Deposit asset liquidity.", @@ -4985,6 +5329,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "updateTenant", + "description": "Update a tenant.", + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateTenantInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantMutationResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "updateWalletAddress", "description": "Update an existing wallet address.", @@ -5287,6 +5664,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.", @@ -6318,7 +6707,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** assets after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** assets after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -6498,6 +6887,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": { @@ -6587,6 +6988,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": { @@ -6810,6 +7223,116 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenant", + "description": "Retrieve a tenant of the instance.", + "args": [ + { + "name": "id", + "description": "Unique identifier of the tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tenants", + "description": "As an operator, fetch a paginated list of tenants on the instance.", + "args": [ + { + "name": "after", + "description": "Forward pagination: Cursor (tenant ID) to start retrieving tenants after this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "before", + "description": "Backward pagination: Cursor (tenant ID) to start retrieving tenants before this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "first", + "description": "Forward pagination: Limit the result to the first **n** tenants after the `after` cursor.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "last", + "description": "Backward pagination: Limit the result to the last **n** tenants before the `before` cursor.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sortOrder", + "description": "Specify the sort order of tenants based on their creation date, either ascending or descending.", + "type": { + "kind": "ENUM", + "name": "SortOrder", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantsConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "walletAddress", "description": "Fetch a wallet address by its ID.", @@ -6931,9 +7454,21 @@ "defaultValue": null, "isDeprecated": false, "deprecationReason": null - } - ], - "type": { + }, + { + "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": { "kind": "NON_NULL", "name": null, "ofType": { @@ -7033,6 +7568,22 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "whoami", + "description": "Determine if the requester has operator permissions", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WhoamiResponse", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -7153,6 +7704,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.", @@ -7499,19 +8066,500 @@ }, { "kind": "INPUT_OBJECT", - "name": "SetFeeInput", + "name": "SetFeeInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "assetId", + "description": "Unique identifier of the asset id to add the fees to.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fee", + "description": "Fee values", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FeeDetails", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idempotencyKey", + "description": "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).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": "Type of fee, either sending or receiving.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FeeType", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SetFeeResponse", + "description": null, + "fields": [ + { + "name": "fee", + "description": "The fee that was set.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "SortOrder", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ASC", + "description": "Sort the results in ascending order.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DESC", + "description": "Sort the results in descending order.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Tenant", + "description": null, + "fields": [ + { + "name": "apiSecret", + "description": "Secret used to secure requests made for this tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "The date and time that this tenant was created.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": "The date and time that this tenant was deleted.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": "Contact email of the tenant owner.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Unique identifier of the tenant.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpConsentUrl", + "description": "URL of the tenant's identity provider's consent screen.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpSecret", + "description": "Secret used to secure requests from the tenant's identity provider.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicName", + "description": "Public name for the tenant.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settings", + "description": "List of settings for the tenant.", + "args": [ + { + "name": "after", + "description": "Forward pagination: Cursor (wallet address key ID) to start retrieving settings after this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "before", + "description": "Backward pagination: Cursor (wallet address key ID) to start retrieving keys before this point.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "first", + "description": "Forward pagination: Limit the result to the first **n** keys after the `after` cursor.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "last", + "description": "Backward pagination: Limit the result to the last **n** keys before the `before` cursor.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sortOrder", + "description": "Specify the sort order of keys based on their creation data, either ascending or descending.", + "type": { + "kind": "ENUM", + "name": "SortOrder", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "TenantSettingsConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Model", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantEdge", + "description": null, + "fields": [ + { + "name": "cursor", + "description": "A cursor for paginating through the tenants.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "A tenant node in the list.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantMutationResponse", + "description": null, + "fields": [ + { + "name": "tenant", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tenant", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantSetting", + "description": null, + "fields": [ + { + "name": "key", + "description": "Key for this setting.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": "Value of a setting for this key.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantSettingEdge", + "description": null, + "fields": [ + { + "name": "cursor", + "description": "A cursor for paginating through the tenants.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "A tenant setting node in the list.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantSetting", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TenantSettingInput", "description": null, "fields": null, "inputFields": [ { - "name": "assetId", - "description": "Unique identifier of the asset id to add the fees to.", + "name": "key", + "description": "Key for this setting.", "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", - "name": "ID", + "name": "String", "ofType": null } }, @@ -7520,107 +8568,125 @@ "deprecationReason": null }, { - "name": "fee", - "description": "Fee values", + "name": "value", + "description": "Value of a setting for this key.", "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "INPUT_OBJECT", - "name": "FeeDetails", + "kind": "SCALAR", + "name": "String", "ofType": null } }, "defaultValue": null, "isDeprecated": false, "deprecationReason": null - }, + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TenantSettingsConnection", + "description": null, + "fields": [ { - "name": "idempotencyKey", - "description": "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).", + "name": "edges", + "description": "A list of edges representing tenant settings and cursors for pagination.", + "args": [], "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantSettingEdge", + "ofType": null + } + } + } }, - "defaultValue": null, "isDeprecated": false, "deprecationReason": null }, { - "name": "type", - "description": "Type of fee, either sending or receiving.", + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [], "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "ENUM", - "name": "FeeType", + "kind": "OBJECT", + "name": "PageInfo", "ofType": null } }, - "defaultValue": null, "isDeprecated": false, "deprecationReason": null } ], - "interfaces": null, + "inputFields": null, + "interfaces": [], "enumValues": null, "possibleTypes": null }, { "kind": "OBJECT", - "name": "SetFeeResponse", + "name": "TenantsConnection", "description": null, "fields": [ { - "name": "fee", - "description": "The fee that was set.", + "name": "edges", + "description": "A list of edges representing tenants and cursors for pagination.", "args": [], "type": { - "kind": "OBJECT", - "name": "Fee", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TenantEdge", + "ofType": null + } + } + } }, "isDeprecated": false, "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "SortOrder", - "description": null, - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ASC", - "description": "Sort the results in ascending order.", - "isDeprecated": false, - "deprecationReason": null }, { - "name": "DESC", - "description": "Sort the results in descending order.", + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null } ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "String", - "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", - "fields": null, "inputFields": null, - "interfaces": null, + "interfaces": [], "enumValues": null, "possibleTypes": null }, @@ -7992,6 +9058,93 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateTenantInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "apiSecret", + "description": "Secret used to secure requests made for this tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": "Contact email of the tenant owner.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Unique identifier of the tenant.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpConsentUrl", + "description": "URL of the tenant's identity provider's consent screen.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "idpSecret", + "description": "Secret used to secure requests from the tenant's identity provider.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "publicName", + "description": "Public name for the tenant.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "UpdateWalletAddressInput", @@ -8410,7 +9563,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** quotes after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** quotes after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -8469,6 +9622,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "tenantId", + "description": "Tenant ID of the wallet address.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "url", "description": "Wallet Address URL.", @@ -8515,7 +9680,7 @@ }, { "name": "first", - "description": "Foward pagination: Limit the result to the first **n** keys after the `after` cursor.", + "description": "Forward pagination: Limit the result to the first **n** keys after the `after` cursor.", "type": { "kind": "SCALAR", "name": "Int", @@ -9158,6 +10323,49 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "WhoamiResponse", + "description": null, + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isOperator", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "WithdrawEventLiquidityInput", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 46dad2dedb..f1c22ba2a9 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; }; @@ -364,6 +367,32 @@ export type CreateReceiverResponse = { receiver?: Maybe; }; +export type CreateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['input']; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; + /** Initial settings for tenant. */ + settings?: InputMaybe>; +}; + +export type CreateTenantSettingsInput = { + /** List of a settings for a tenant. */ + settings: Array; +}; + +export type CreateTenantSettingsMutationResponse = { + __typename?: 'CreateTenantSettingsMutationResponse'; + /** New tenant settings. */ + settings: Array; +}; + export type CreateWalletAddressInput = { /** Additional properties associated with the wallet address. */ additionalProperties?: InputMaybe>; @@ -373,6 +402,8 @@ export type CreateWalletAddressInput = { idempotencyKey?: InputMaybe; /** Public name associated with the wallet address. This is visible to anyone with the wallet address URL. */ publicName?: InputMaybe; + /** Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature. */ + tenantId?: InputMaybe; /** Wallet address URL. This cannot be changed. */ url: Scalars['String']['input']; }; @@ -440,6 +471,11 @@ export type DeletePeerMutationResponse = { success: Scalars['Boolean']['output']; }; +export type DeleteTenantMutationResponse = { + __typename?: 'DeleteTenantMutationResponse'; + success: Scalars['Boolean']['output']; +}; + export type DepositAssetLiquidityInput = { /** Amount of liquidity to deposit. */ amount: Scalars['UInt64']['input']; @@ -580,6 +616,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']; }; @@ -721,6 +759,9 @@ export type Mutation = { createQuote: QuoteResponse; /** Create an internal or external Open Payments incoming payment. The receiver has a wallet address on either this or another Open Payments resource server. */ createReceiver: CreateReceiverResponse; + /** As an operator, create a tenant. */ + createTenant: TenantMutationResponse; + createTenantSettings?: Maybe; /** Create a new wallet address. */ createWalletAddress: CreateWalletAddressMutationResponse; /** Add a public key to a wallet address that is used to verify Open Payments requests. */ @@ -731,6 +772,8 @@ export type Mutation = { deleteAsset: DeleteAssetMutationResponse; /** Delete a peer. */ deletePeer: DeletePeerMutationResponse; + /** Delete a tenant. */ + deleteTenant: DeleteTenantMutationResponse; /** Deposit asset liquidity. */ depositAssetLiquidity?: Maybe; /** @@ -756,6 +799,8 @@ export type Mutation = { updateIncomingPayment: IncomingPaymentResponse; /** Update an existing peer. */ updatePeer: UpdatePeerMutationResponse; + /** Update a tenant. */ + updateTenant: TenantMutationResponse; /** Update an existing wallet address. */ updateWalletAddress: UpdateWalletAddressMutationResponse; /** Void liquidity withdrawal. Withdrawals are two-phase commits and are rolled back via this mutation. */ @@ -843,6 +888,16 @@ export type MutationCreateReceiverArgs = { }; +export type MutationCreateTenantArgs = { + input: CreateTenantInput; +}; + + +export type MutationCreateTenantSettingsArgs = { + input: CreateTenantSettingsInput; +}; + + export type MutationCreateWalletAddressArgs = { input: CreateWalletAddressInput; }; @@ -868,6 +923,11 @@ export type MutationDeletePeerArgs = { }; +export type MutationDeleteTenantArgs = { + id: Scalars['String']['input']; +}; + + export type MutationDepositAssetLiquidityArgs = { input: DepositAssetLiquidityInput; }; @@ -923,6 +983,11 @@ export type MutationUpdatePeerArgs = { }; +export type MutationUpdateTenantArgs = { + input: UpdateTenantInput; +}; + + export type MutationUpdateWalletAddressArgs = { input: UpdateWalletAddressInput; }; @@ -967,6 +1032,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']; }; @@ -1150,6 +1217,10 @@ export type Query = { quote?: Maybe; /** Retrieve an Open Payments incoming payment by receiver ID. The receiver's wallet address can be hosted on this server or a remote Open Payments resource server. */ receiver?: Maybe; + /** Retrieve a tenant of the instance. */ + tenant: Tenant; + /** As an operator, fetch a paginated list of tenants on the instance. */ + tenants: TenantsConnection; /** Fetch a wallet address by its ID. */ walletAddress?: Maybe; /** Get a wallet address by its url if it exists */ @@ -1158,6 +1229,8 @@ export type Query = { walletAddresses: WalletAddressesConnection; /** Fetch a paginated list of webhook events. */ webhookEvents: WebhookEventsConnection; + /** Determine if the requester has operator permissions */ + whoami: WhoamiResponse; }; @@ -1204,6 +1277,7 @@ export type QueryOutgoingPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1214,6 +1288,7 @@ export type QueryPaymentsArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1247,6 +1322,20 @@ export type QueryReceiverArgs = { }; +export type QueryTenantArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryTenantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + sortOrder?: InputMaybe; +}; + + export type QueryWalletAddressArgs = { id: Scalars['String']['input']; }; @@ -1263,6 +1352,7 @@ export type QueryWalletAddressesArgs = { first?: InputMaybe; last?: InputMaybe; sortOrder?: InputMaybe; + tenantId?: InputMaybe; }; @@ -1291,6 +1381,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']; }; @@ -1376,6 +1468,89 @@ export enum SortOrder { Desc = 'DESC' } +export type Tenant = Model & { + __typename?: 'Tenant'; + /** Secret used to secure requests made for this tenant. */ + apiSecret: Scalars['String']['output']; + /** The date and time that this tenant was created. */ + createdAt: Scalars['String']['output']; + /** The date and time that this tenant was deleted. */ + deletedAt?: Maybe; + /** Contact email of the tenant owner. */ + email?: Maybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['output']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: Maybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: Maybe; + /** Public name for the tenant. */ + publicName?: Maybe; + /** List of settings for the tenant. */ + settings?: Maybe; +}; + + +export type TenantSettingsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + sortOrder?: InputMaybe; +}; + +export type TenantEdge = { + __typename?: 'TenantEdge'; + /** A cursor for paginating through the tenants. */ + cursor: Scalars['String']['output']; + /** A tenant node in the list. */ + node: Tenant; +}; + +export type TenantMutationResponse = { + __typename?: 'TenantMutationResponse'; + tenant: Tenant; +}; + +export type TenantSetting = { + __typename?: 'TenantSetting'; + /** Key for this setting. */ + key: Scalars['String']['output']; + /** Value of a setting for this key. */ + value: Scalars['String']['output']; +}; + +export type TenantSettingEdge = { + __typename?: 'TenantSettingEdge'; + /** A cursor for paginating through the tenants. */ + cursor: Scalars['String']['output']; + /** A tenant setting node in the list. */ + node: TenantSetting; +}; + +export type TenantSettingInput = { + /** Key for this setting. */ + key: Scalars['String']['input']; + /** Value of a setting for this key. */ + value: Scalars['String']['input']; +}; + +export type TenantSettingsConnection = { + __typename?: 'TenantSettingsConnection'; + /** A list of edges representing tenant settings and cursors for pagination. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + +export type TenantsConnection = { + __typename?: 'TenantsConnection'; + /** A list of edges representing tenants and cursors for pagination. */ + edges: Array; + /** Information to aid in pagination. */ + pageInfo: PageInfo; +}; + export enum TransferState { /** The accounting transfer is pending */ Pending = 'PENDING', @@ -1448,6 +1623,21 @@ export type UpdatePeerMutationResponse = { peer?: Maybe; }; +export type UpdateTenantInput = { + /** Secret used to secure requests made for this tenant. */ + apiSecret?: InputMaybe; + /** Contact email of the tenant owner. */ + email?: InputMaybe; + /** Unique identifier of the tenant. */ + id: Scalars['ID']['input']; + /** URL of the tenant's identity provider's consent screen. */ + idpConsentUrl?: InputMaybe; + /** Secret used to secure requests from the tenant's identity provider. */ + idpSecret?: InputMaybe; + /** Public name for the tenant. */ + publicName?: InputMaybe; +}; + export type UpdateWalletAddressInput = { /** Additional properties associated with this wallet address. */ additionalProperties?: InputMaybe>; @@ -1496,6 +1686,8 @@ export type WalletAddress = Model & { quotes?: Maybe; /** The current status of the wallet, either active or inactive. */ status: WalletAddressStatus; + /** Tenant ID of the wallet address. */ + tenantId?: Maybe; /** Wallet Address URL. */ url: Scalars['String']['output']; /** List of keys associated with this wallet address */ @@ -1640,6 +1832,12 @@ export type WebhookEventsEdge = { node: WebhookEvent; }; +export type WhoamiResponse = { + __typename?: 'WhoamiResponse'; + id: Scalars['String']['output']; + isOperator: Scalars['Boolean']['output']; +}; + export type WithdrawEventLiquidityInput = { /** Unique identifier of the event to withdraw liquidity from. */ eventId: Scalars['String']['input']; @@ -1718,7 +1916,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1756,6 +1954,9 @@ export type ResolversTypes = { CreateQuoteInput: ResolverTypeWrapper>; CreateReceiverInput: ResolverTypeWrapper>; CreateReceiverResponse: ResolverTypeWrapper>; + CreateTenantInput: ResolverTypeWrapper>; + CreateTenantSettingsInput: ResolverTypeWrapper>; + CreateTenantSettingsMutationResponse: ResolverTypeWrapper>; CreateWalletAddressInput: ResolverTypeWrapper>; CreateWalletAddressKeyInput: ResolverTypeWrapper>; CreateWalletAddressKeyMutationResponse: ResolverTypeWrapper>; @@ -1766,6 +1967,7 @@ export type ResolversTypes = { DeleteAssetMutationResponse: ResolverTypeWrapper>; DeletePeerInput: ResolverTypeWrapper>; DeletePeerMutationResponse: ResolverTypeWrapper>; + DeleteTenantMutationResponse: ResolverTypeWrapper>; DepositAssetLiquidityInput: ResolverTypeWrapper>; DepositEventLiquidityInput: ResolverTypeWrapper>; DepositOutgoingPaymentLiquidityInput: ResolverTypeWrapper>; @@ -1825,6 +2027,14 @@ export type ResolversTypes = { SetFeeResponse: ResolverTypeWrapper>; SortOrder: ResolverTypeWrapper>; String: ResolverTypeWrapper>; + Tenant: ResolverTypeWrapper>; + TenantEdge: ResolverTypeWrapper>; + TenantMutationResponse: ResolverTypeWrapper>; + TenantSetting: ResolverTypeWrapper>; + TenantSettingEdge: ResolverTypeWrapper>; + TenantSettingInput: ResolverTypeWrapper>; + TenantSettingsConnection: ResolverTypeWrapper>; + TenantsConnection: ResolverTypeWrapper>; TransferState: ResolverTypeWrapper>; TransferType: ResolverTypeWrapper>; TriggerWalletAddressEventsInput: ResolverTypeWrapper>; @@ -1835,6 +2045,7 @@ export type ResolversTypes = { UpdateIncomingPaymentInput: ResolverTypeWrapper>; UpdatePeerInput: ResolverTypeWrapper>; UpdatePeerMutationResponse: ResolverTypeWrapper>; + UpdateTenantInput: ResolverTypeWrapper>; UpdateWalletAddressInput: ResolverTypeWrapper>; UpdateWalletAddressMutationResponse: ResolverTypeWrapper>; VoidLiquidityWithdrawalInput: ResolverTypeWrapper>; @@ -1851,6 +2062,7 @@ export type ResolversTypes = { WebhookEventFilter: ResolverTypeWrapper>; WebhookEventsConnection: ResolverTypeWrapper>; WebhookEventsEdge: ResolverTypeWrapper>; + WhoamiResponse: ResolverTypeWrapper>; WithdrawEventLiquidityInput: ResolverTypeWrapper>; }; @@ -1888,6 +2100,9 @@ export type ResolversParentTypes = { CreateQuoteInput: Partial; CreateReceiverInput: Partial; CreateReceiverResponse: Partial; + CreateTenantInput: Partial; + CreateTenantSettingsInput: Partial; + CreateTenantSettingsMutationResponse: Partial; CreateWalletAddressInput: Partial; CreateWalletAddressKeyInput: Partial; CreateWalletAddressKeyMutationResponse: Partial; @@ -1897,6 +2112,7 @@ export type ResolversParentTypes = { DeleteAssetMutationResponse: Partial; DeletePeerInput: Partial; DeletePeerMutationResponse: Partial; + DeleteTenantMutationResponse: Partial; DepositAssetLiquidityInput: Partial; DepositEventLiquidityInput: Partial; DepositOutgoingPaymentLiquidityInput: Partial; @@ -1949,6 +2165,14 @@ export type ResolversParentTypes = { SetFeeInput: Partial; SetFeeResponse: Partial; String: Partial; + Tenant: Partial; + TenantEdge: Partial; + TenantMutationResponse: Partial; + TenantSetting: Partial; + TenantSettingEdge: Partial; + TenantSettingInput: Partial; + TenantSettingsConnection: Partial; + TenantsConnection: Partial; TriggerWalletAddressEventsInput: Partial; TriggerWalletAddressEventsMutationResponse: Partial; UInt8: Partial; @@ -1957,6 +2181,7 @@ export type ResolversParentTypes = { UpdateIncomingPaymentInput: Partial; UpdatePeerInput: Partial; UpdatePeerMutationResponse: Partial; + UpdateTenantInput: Partial; UpdateWalletAddressInput: Partial; UpdateWalletAddressMutationResponse: Partial; VoidLiquidityWithdrawalInput: Partial; @@ -1972,6 +2197,7 @@ export type ResolversParentTypes = { WebhookEventFilter: Partial; WebhookEventsConnection: Partial; WebhookEventsEdge: Partial; + WhoamiResponse: Partial; WithdrawEventLiquidityInput: Partial; }; @@ -2023,6 +2249,7 @@ export type AssetResolvers, ParentType, ContextType>; scale?: Resolver; sendingFee?: Resolver, ParentType, ContextType>; + tenantId?: Resolver; withdrawalThreshold?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2073,6 +2300,11 @@ export type CreateReceiverResponseResolvers; }; +export type CreateTenantSettingsMutationResponseResolvers = { + settings?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateWalletAddressKeyMutationResponseResolvers = { walletAddressKey?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2093,6 +2325,11 @@ export type DeletePeerMutationResponseResolvers; }; +export type DeleteTenantMutationResponseResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FeeResolvers = { assetId?: Resolver; basisPoints?: Resolver; @@ -2136,6 +2373,7 @@ export type IncomingPaymentResolvers, ParentType, ContextType>; receivedAmount?: Resolver; state?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2176,7 +2414,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2197,11 +2435,14 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createQuote?: Resolver>; createReceiver?: Resolver>; + createTenant?: Resolver>; + createTenantSettings?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddress?: Resolver>; createWalletAddressKey?: Resolver, ParentType, ContextType, RequireFields>; createWalletAddressWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; deleteAsset?: Resolver>; deletePeer?: Resolver>; + deleteTenant?: Resolver>; depositAssetLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; depositOutgoingPaymentLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2213,6 +2454,7 @@ export type MutationResolvers>; updateIncomingPayment?: Resolver>; updatePeer?: Resolver>; + updateTenant?: Resolver>; updateWalletAddress?: Resolver>; voidLiquidityWithdrawal?: Resolver, ParentType, ContextType, RequireFields>; withdrawEventLiquidity?: Resolver, ParentType, ContextType, RequireFields>; @@ -2233,6 +2475,7 @@ export type OutgoingPaymentResolvers; state?: Resolver; stateAttempts?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2325,10 +2568,13 @@ export type QueryResolvers>; quote?: Resolver, ParentType, ContextType, RequireFields>; receiver?: Resolver, ParentType, ContextType, RequireFields>; + tenant?: Resolver>; + tenants?: Resolver>; walletAddress?: Resolver, ParentType, ContextType, RequireFields>; walletAddressByUrl?: Resolver, ParentType, ContextType, RequireFields>; walletAddresses?: Resolver>; webhookEvents?: Resolver>; + whoami?: Resolver; }; export type QuoteResolvers = { @@ -2339,6 +2585,7 @@ export type QuoteResolvers; receiveAmount?: Resolver; receiver?: Resolver; + tenantId?: Resolver; walletAddressId?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -2383,6 +2630,54 @@ export type SetFeeResponseResolvers; }; +export type TenantResolvers = { + apiSecret?: Resolver; + createdAt?: Resolver; + deletedAt?: Resolver, ParentType, ContextType>; + email?: Resolver, ParentType, ContextType>; + id?: Resolver; + idpConsentUrl?: Resolver, ParentType, ContextType>; + idpSecret?: Resolver, ParentType, ContextType>; + publicName?: Resolver, ParentType, ContextType>; + settings?: Resolver, ParentType, ContextType, Partial>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantMutationResponseResolvers = { + tenant?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingResolvers = { + key?: Resolver; + value?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantSettingsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type TenantsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type TriggerWalletAddressEventsMutationResponseResolvers = { count?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2417,6 +2712,7 @@ export type WalletAddressResolvers, ParentType, ContextType>; quotes?: Resolver, ParentType, ContextType, Partial>; status?: Resolver; + tenantId?: Resolver, ParentType, ContextType>; url?: Resolver; walletAddressKeys?: Resolver, ParentType, ContextType, Partial>; __isTypeOf?: IsTypeOfResolverFn; @@ -2487,6 +2783,12 @@ export type WebhookEventsEdgeResolvers; }; +export type WhoamiResponseResolvers = { + id?: Resolver; + isOperator?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AccountingTransfer?: AccountingTransferResolvers; AccountingTransferConnection?: AccountingTransferConnectionResolvers; @@ -2502,10 +2804,12 @@ export type Resolvers = { CreateOrUpdatePeerByUrlMutationResponse?: CreateOrUpdatePeerByUrlMutationResponseResolvers; CreatePeerMutationResponse?: CreatePeerMutationResponseResolvers; CreateReceiverResponse?: CreateReceiverResponseResolvers; + CreateTenantSettingsMutationResponse?: CreateTenantSettingsMutationResponseResolvers; CreateWalletAddressKeyMutationResponse?: CreateWalletAddressKeyMutationResponseResolvers; CreateWalletAddressMutationResponse?: CreateWalletAddressMutationResponseResolvers; DeleteAssetMutationResponse?: DeleteAssetMutationResponseResolvers; DeletePeerMutationResponse?: DeletePeerMutationResponseResolvers; + DeleteTenantMutationResponse?: DeleteTenantMutationResponseResolvers; Fee?: FeeResolvers; FeeEdge?: FeeEdgeResolvers; FeesConnection?: FeesConnectionResolvers; @@ -2539,6 +2843,13 @@ export type Resolvers = { Receiver?: ReceiverResolvers; RevokeWalletAddressKeyMutationResponse?: RevokeWalletAddressKeyMutationResponseResolvers; SetFeeResponse?: SetFeeResponseResolvers; + Tenant?: TenantResolvers; + TenantEdge?: TenantEdgeResolvers; + TenantMutationResponse?: TenantMutationResponseResolvers; + TenantSetting?: TenantSettingResolvers; + TenantSettingEdge?: TenantSettingEdgeResolvers; + TenantSettingsConnection?: TenantSettingsConnectionResolvers; + TenantsConnection?: TenantsConnectionResolvers; TriggerWalletAddressEventsMutationResponse?: TriggerWalletAddressEventsMutationResponseResolvers; UInt8?: GraphQLScalarType; UInt64?: GraphQLScalarType; @@ -2555,5 +2866,6 @@ export type Resolvers = { WebhookEvent?: WebhookEventResolvers; WebhookEventsConnection?: WebhookEventsConnectionResolvers; WebhookEventsEdge?: WebhookEventsEdgeResolvers; + WhoamiResponse?: WhoamiResponseResolvers; }; diff --git a/packages/backend/src/graphql/middleware/index.ts b/packages/backend/src/graphql/middleware/index.ts index d8e490e4c4..f2b7d0ff53 100644 --- a/packages/backend/src/graphql/middleware/index.ts +++ b/packages/backend/src/graphql/middleware/index.ts @@ -1,9 +1,14 @@ import { GraphQLError } from 'graphql' import { IMiddleware } from 'graphql-middleware' -import { ApolloContext } from '../../app' +import { + ApolloContext, + ForTenantIdContext, + TenantedApolloContext +} from '../../app' import { CacheDataStore } from '../../middleware/cache/data-stores' import { lockMiddleware, Lock } from '../../middleware/lock' import { cacheMiddleware } from '../../middleware/cache' +import { validateTenantMiddleware } from '../../middleware/tenant' export function lockGraphQLMutationMiddleware(lock: Lock): { Mutation: IMiddleware @@ -46,3 +51,17 @@ export function idempotencyGraphQLMiddleware( } } } + +export function setForTenantIdGraphQLMutationMiddleware(): { + Mutation: IMiddleware +} { + return { + Mutation: async (resolve, root, args, context, info) => { + return validateTenantMiddleware({ + deps: { context }, + next: () => resolve(root, args, context, info), + tenantIdInput: args?.input?.tenantId + }) + } + } +} diff --git a/packages/backend/src/graphql/resolvers/asset.test.ts b/packages/backend/src/graphql/resolvers/asset.test.ts index 7968de3f98..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 @@ -132,7 +137,7 @@ describe('Asset Resolvers', (): void => { test('Returns error for duplicate asset', async (): Promise => { const input = randomAsset() - await assetService.create(input) + await assetService.create({ ...input, tenantId: Config.operatorTenantId }) expect.assertions(2) try { @@ -212,12 +217,62 @@ 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 => { test('Can get an asset', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -283,6 +338,7 @@ describe('Asset Resolvers', (): void => { test('Can get an asset by code and scale', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -349,7 +405,10 @@ describe('Asset Resolvers', (): void => { { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Sending }, { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Receiving } ])('Can get an asset with fee of %p', async (fee): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) let expectedFee = null @@ -469,6 +528,7 @@ describe('Asset Resolvers', (): void => { createModel: () => assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) as Promise, @@ -480,6 +540,7 @@ describe('Asset Resolvers', (): void => { for (let i = 0; i < 2; i++) { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -620,6 +681,7 @@ describe('Asset Resolvers', (): void => { beforeEach(async (): Promise => { asset = (await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold })) as AssetModel diff --git a/packages/backend/src/graphql/resolvers/asset.ts b/packages/backend/src/graphql/resolvers/asset.ts index 50638d721c..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 { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { feeToGraphql } from './fee' @@ -15,37 +15,45 @@ import { Fee, FeeType } from '../../fee/model' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getAssets: QueryResolvers['assets'] = async ( - parent, - args, - ctx -): Promise => { - const assetService = await ctx.container.use('assetService') - const { sortOrder, ...pagination } = args - const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc - const assets = await assetService.getPage(pagination, order) - const pageInfo = await getPageInfo({ - getPage: (pagination: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder), - page: assets, - sortOrder: order - }) - return { - pageInfo, - edges: assets.map((asset: Asset) => ({ - cursor: asset.id, - node: assetToGraphql(asset) - })) +export const getAssets: QueryResolvers['assets'] = + async (parent, args, ctx): Promise => { + const assetService = await ctx.container.use('assetService') + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const assets = await assetService.getPage({ + pagination, + sortOrder: order, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id + }) + const pageInfo = await getPageInfo({ + getPage: (pagination: Pagination, sortOrder?: SortOrder) => + assetService.getPage({ + pagination, + sortOrder, + tenantId: ctx.isOperator ? undefined : ctx.tenant.id + }), + page: assets, + sortOrder: order + }) + return { + pageInfo, + edges: assets.map((asset: Asset) => ({ + cursor: asset.id, + node: assetToGraphql(asset) + })) + } } -} -export const getAsset: QueryResolvers['asset'] = async ( +export const getAsset: QueryResolvers['asset'] = async ( parent, args, ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.get(args.id) + const asset = await assetService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!asset) { throw new GraphQLError('Asset not found', { extensions: { @@ -56,21 +64,38 @@ export const getAsset: QueryResolvers['asset'] = async ( return assetToGraphql(asset) } -export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = +export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = async (parent, args, ctx): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.getByCodeAndScale(args.code, args.scale) + const asset = await assetService.getByCodeAndScale({ + code: args.code, + scale: args.scale, + tenantId: ctx.tenant.id + }) return asset ? assetToGraphql(asset) : null } -export const createAsset: MutationResolvers['createAsset'] = +export const createAsset: MutationResolvers['createAsset'] = async ( parent, args, ctx ): Promise => { + const 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) + const assetOrError = await assetService.create({ + ...args.input, + tenantId + }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { extensions: { @@ -83,7 +108,7 @@ export const createAsset: MutationResolvers['createAsset'] = } } -export const updateAsset: MutationResolvers['updateAsset'] = +export const updateAsset: MutationResolvers['updateAsset'] = async ( parent, args, @@ -93,7 +118,8 @@ export const updateAsset: MutationResolvers['updateAsset'] = const assetOrError = await assetService.update({ id: args.input.id, withdrawalThreshold: args.input.withdrawalThreshold ?? null, - liquidityThreshold: args.input.liquidityThreshold ?? null + liquidityThreshold: args.input.liquidityThreshold ?? null, + tenantId: ctx.tenant.id }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { @@ -107,7 +133,7 @@ export const updateAsset: MutationResolvers['updateAsset'] = } } -export const getAssetSendingFee: AssetResolvers['sendingFee'] = +export const getAssetSendingFee: AssetResolvers['sendingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -119,7 +145,7 @@ export const getAssetSendingFee: AssetResolvers['sendingFee'] = return feeToGraphql(fee) } -export const getAssetReceivingFee: AssetResolvers['receivingFee'] = +export const getAssetReceivingFee: AssetResolvers['receivingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -131,7 +157,7 @@ export const getAssetReceivingFee: AssetResolvers['receivingFee'] return feeToGraphql(fee) } -export const getFees: AssetResolvers['fees'] = async ( +export const getFees: AssetResolvers['fees'] = async ( parent, args, ctx @@ -159,7 +185,7 @@ export const getFees: AssetResolvers['fees'] = async ( } } -export const deleteAsset: MutationResolvers['deleteAsset'] = +export const deleteAsset: MutationResolvers['deleteAsset'] = async ( _, args, @@ -168,6 +194,7 @@ export const deleteAsset: MutationResolvers['deleteAsset'] = const assetService = await ctx.container.use('assetService') const assetOrError = await assetService.delete({ id: args.input.id, + tenantId: ctx.tenant.id, deletedAt: new Date() }) @@ -189,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 50af7a5d4c..2f3ac34706 100644 --- a/packages/backend/src/graphql/resolvers/combined_payments.test.ts +++ b/packages/backend/src/graphql/resolvers/combined_payments.test.ts @@ -50,11 +50,13 @@ describe('Payment', (): void => { test('Can get payments', async (): Promise => { const { id: outWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const client = 'client-test' const outgoingPayment = await createOutgoingPayment(deps, { + tenantId: Config.operatorTenantId, walletAddressId: outWalletAddressId, client: client, method: 'ilp', @@ -68,11 +70,13 @@ describe('Payment', (): void => { }) const { id: inWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const incomingPayment = await createIncomingPayment(deps, { walletAddressId: inWalletAddressId, - client: client + client: client, + tenantId: Config.operatorTenantId }) const query = await appContainer.apolloClient @@ -146,6 +150,7 @@ describe('Payment', (): void => { test('Can filter payments by type and wallet address', async (): Promise => { const { id: outWalletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) @@ -161,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', @@ -168,9 +174,11 @@ describe('Payment', (): void => { }) const { id: outWalletAddressId2 } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, 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 0db5f7cf66..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 => { @@ -55,8 +57,12 @@ describe('Incoming Payment Resolver', (): void => { describe('Wallet address incoming payments', (): void => { beforeEach(async (): Promise => { - walletAddressId = (await createWalletAddress(deps, { assetId: asset.id })) - .id + walletAddressId = ( + await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, + assetId: asset.id + }) + ).id }) getPageTests({ @@ -74,7 +80,8 @@ describe('Incoming Payment Resolver', (): void => { metadata: { description: `IncomingPayment`, externalRef: '#123' - } + }, + tenantId }), pagedQuery: 'incomingPayments', parent: { @@ -106,6 +113,7 @@ describe('Incoming Payment Resolver', (): void => { async ({ metadata, expiresAt, withAmount }): Promise => { const incomingAmount = withAmount ? amount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createIncomingPayment(deps, { @@ -113,7 +121,8 @@ describe('Incoming Payment Resolver', (): void => { client, metadata, expiresAt, - incomingAmount + incomingAmount, + tenantId }) const createSpy = jest @@ -162,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: { @@ -232,7 +241,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('Internal server error', async (): Promise => { @@ -277,7 +286,10 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId + }) }) }) @@ -294,7 +306,8 @@ describe('Incoming Payment Resolver', (): void => { value: BigInt(56), assetCode: asset.code, assetScale: asset.scale - } + }, + tenantId }) } @@ -305,6 +318,7 @@ describe('Incoming Payment Resolver', (): void => { } beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) payment = await createPayment({ walletAddressId, metadata }) @@ -462,13 +476,15 @@ describe('Incoming Payment Resolver', (): void => { async ({ metadata }): Promise => { const incomingAmount = amount ? amount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) const payment = await createIncomingPayment(deps, { walletAddressId, metadata, expiresAt, - incomingAmount + incomingAmount, + tenantId }) const input = { id: payment.id, @@ -503,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: { @@ -558,7 +574,7 @@ describe('Incoming Payment Resolver', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('Internal server error', async (): Promise => { @@ -604,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 2c191b30e8..303b10dbee 100644 --- a/packages/backend/src/graphql/resolvers/index.ts +++ b/packages/backend/src/graphql/resolvers/index.ts @@ -77,6 +77,15 @@ import { GraphQLJSONObject } from 'graphql-scalars' import { getCombinedPayments } from './combined_payments' import { createOrUpdatePeerByUrl } from './auto-peering' import { getAccountingTransfers } from './accounting_transfer' +import { + whoami, + createTenant, + updateTenant, + deleteTenant, + getTenant, + getTenants +} from './tenant' +import { createTenantSettings, getTenantSettings } from './tenant_settings' export const resolvers: Resolvers = { UInt8: GraphQLUInt8, @@ -92,6 +101,7 @@ export const resolvers: Resolvers = { liquidity: getPeerLiquidity }, Query: { + whoami, walletAddress: getWalletAddress, walletAddressByUrl: getWalletAddressByUrl, walletAddresses: getWalletAddresses, @@ -108,7 +118,9 @@ export const resolvers: Resolvers = { webhookEvents: getWebhookEvents, payments: getCombinedPayments, accountingTransfers: getAccountingTransfers, - receiver: getReceiver + receiver: getReceiver, + tenant: getTenant, + tenants: getTenants }, WalletAddress: { liquidity: getWalletAddressLiquidity, @@ -118,6 +130,9 @@ export const resolvers: Resolvers = { walletAddressKeys: getWalletAddressKeys, additionalProperties: getWalletAddressAdditionalProperties }, + Tenant: { + settings: getTenantSettings + }, IncomingPayment: { liquidity: getIncomingPaymentLiquidity }, @@ -161,6 +176,10 @@ export const resolvers: Resolvers = { createIncomingPaymentWithdrawal, createOutgoingPaymentWithdrawal, setFee, - updateIncomingPayment + updateIncomingPayment, + createTenant, + updateTenant, + deleteTenant, + createTenantSettings } } diff --git a/packages/backend/src/graphql/resolvers/liquidity.test.ts b/packages/backend/src/graphql/resolvers/liquidity.test.ts index e0968b27e1..457099cb36 100644 --- a/packages/backend/src/graphql/resolvers/liquidity.test.ts +++ b/packages/backend/src/graphql/resolvers/liquidity.test.ts @@ -1015,6 +1015,7 @@ describe('Liquidity Resolvers', (): void => { beforeEach(async (): Promise => { walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) @@ -1742,12 +1743,16 @@ describe('Liquidity Resolvers', (): void => { ) describe('Event Liquidity', (): void => { + let tenantId: string let walletAddress: WalletAddress let incomingPayment: IncomingPayment let payment: OutgoingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + tenantId = Config.operatorTenantId + walletAddress = await createWalletAddress(deps, { + tenantId + }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { walletAddressId, @@ -1756,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()}`, @@ -2157,7 +2164,9 @@ describe('Liquidity Resolvers', (): void => { let outgoingPayment: OutgoingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const walletAddressId = walletAddress.id incomingPayment = await createIncomingPayment(deps, { walletAddressId, @@ -2166,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 dd41c4eb89..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,11 +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, assetId: asset.id }) ).id @@ -108,6 +112,7 @@ describe('OutgoingPayment Resolvers', (): void => { getClient: () => appContainer.apolloClient, createModel: () => createPayment({ + tenantId, walletAddressId }), pagedQuery: 'outgoingPayments' @@ -135,13 +140,16 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const firstReceiverWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const secondWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const secondReceiverWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) @@ -156,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', @@ -167,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', @@ -326,10 +338,14 @@ describe('OutgoingPayment Resolvers', (): void => { const grantId = uuid() const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) - const payment = await createPayment({ walletAddressId }, grantId) + const payment = await createPayment( + { tenantId, walletAddressId }, + grantId + ) const query = await appContainer.apolloClient .query({ @@ -365,9 +381,10 @@ describe('OutgoingPayment Resolvers', (): void => { beforeEach(async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + 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 @@ -546,16 +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, assetId: asset.id }) - const payment = await createPayment({ walletAddressId, metadata }) + const payment = await createPayment({ + tenantId, + walletAddressId, + metadata + }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -586,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) }) @@ -634,7 +661,7 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('internal server error', async (): Promise => { @@ -680,18 +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, assetId: asset.id }) - const payment = await createPayment({ walletAddressId: walletAddress.id }) + const payment = await createPayment({ + tenantId, + walletAddressId: walletAddress.id + }) const createSpy = jest .spyOn(outgoingPaymentService, 'create') @@ -728,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) }) @@ -781,7 +817,7 @@ describe('OutgoingPayment Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith(input) + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) test('unknown error', async (): Promise => { @@ -832,18 +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, assetId: asset.id }) - payment = await createPayment({ walletAddressId: walletAddress.id }) + payment = await createPayment({ + tenantId, + walletAddressId: walletAddress.id + }) }) const reasons: (string | undefined)[] = [undefined, 'Not enough balance'] @@ -886,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, @@ -945,17 +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, assetId: asset.id }) ).id @@ -965,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 dcb703edc6..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,6 +74,7 @@ describe('Quote Resolvers', (): void => { describe('Query.quote', (): void => { test('success', async (): Promise => { const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const quote = await createWalletAddressQuote(walletAddressId) @@ -139,7 +143,9 @@ describe('Quote Resolvers', (): void => { } } `, - variables: { quoteId: uuid() } + variables: { + quoteId: uuid() + } }) } catch (error) { expect(error).toBeInstanceOf(ApolloError) @@ -189,6 +195,7 @@ describe('Quote Resolvers', (): void => { `('$type', async ({ withAmount, receiveAmount }): Promise => { const amount = withAmount ? debitAmount : undefined const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) const input = { @@ -204,6 +211,7 @@ describe('Quote Resolvers', (): void => { .mockImplementationOnce(async (opts) => { quote = await createQuote(deps, { ...opts, + tenantId, validDestination: false }) return quote @@ -224,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) }) @@ -290,7 +302,11 @@ describe('Quote Resolvers', (): void => { }) ) } - expect(createSpy).toHaveBeenCalledWith({ ...input, method: 'ilp' }) + expect(createSpy).toHaveBeenCalledWith({ + ...input, + tenantId, + method: 'ilp' + }) }) }) @@ -300,6 +316,7 @@ describe('Quote Resolvers', (): void => { beforeEach(async (): Promise => { walletAddressId = ( await createWalletAddress(deps, { + 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.test.ts b/packages/backend/src/graphql/resolvers/tenant.test.ts new file mode 100644 index 0000000000..177ed85015 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant.test.ts @@ -0,0 +1,503 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { createTestApp, TestContainer } from '../../tests/app' +import { + DeleteTenantMutationResponse, + Tenant, + TenantMutationResponse, + TenantsConnection, + WhoamiResponse +} from '../generated/graphql' +import { initIocContainer } from '../..' +import { Config, IAppConfig } from '../../config/app' +import { createTenant, generateTenantInput } from '../../tests/tenant' +import { ApolloError, gql, NormalizedCacheObject } from '@apollo/client' +import { getPageTests } from './page.test' +import { truncateTables } from '../../tests/tableManager' +import { + createHttpLink, + ApolloLink, + ApolloClient, + InMemoryCache +} from '@apollo/client' +import { setContext } from '@apollo/client/link/context' +import { GraphQLErrorCode } from '../errors' +import { Tenant as TenantModel } from '../../tenants/model' + +function createTenantedApolloClient( + appContainer: TestContainer, + tenantId: string +): ApolloClient { + const httpLink = createHttpLink({ + uri: `http://localhost:${appContainer.app.getAdminPort()}/graphql`, + fetch + }) + const authLink = setContext((_, { headers }) => { + return { + headers: { + ...headers, + 'tenant-id': tenantId + } + } + }) + + const link = ApolloLink.from([authLink, httpLink]) + + return new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) +} + +describe('Tenant Resolvers', (): void => { + let deps: IocContract + let appContainer: TestContainer + let config: IAppConfig + + beforeAll(async (): Promise => { + deps = await initIocContainer({ + ...Config, + dbSchema: 'tenant_service_test_schema' + }) + appContainer = await createTestApp(deps) + config = await deps.use('config') + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex, true) + }) + afterAll(async (): Promise => { + await appContainer.apolloClient.stop() + await appContainer.shutdown() + }) + + describe('whoami', (): void => { + test.each` + isOperator | description + ${true} | ${'operator'} + ${false} | ${'tenant'} + `('whoami query as $description', async ({ isOperator }): Promise => { + const tenant = await createTenant(deps) + const client = isOperator + ? appContainer.apolloClient + : createTenantedApolloClient(appContainer, tenant.id) + + const result = await client + .query({ + query: gql` + query Whoami { + whoami { + id + isOperator + } + } + ` + }) + .then((query): WhoamiResponse => query.data?.whoami) + + expect(result).toEqual({ + id: isOperator ? config.operatorTenantId : tenant.id, + isOperator, + __typename: 'WhoamiResponse' + }) + }) + }) + + describe('Query.tenant', (): void => { + describe('page tests', (): void => { + getPageTests({ + getClient: () => appContainer.apolloClient, + createModel: () => createTenant(deps), + pagedQuery: 'tenants' + }) + + test('Cannot get page as non-operator', async (): Promise => { + const tenant = await createTenant(deps) + const apolloClient = createTenantedApolloClient(appContainer, tenant.id) + try { + expect.assertions(2) + await apolloClient + .query({ + query: gql` + query GetTenants { + tenants { + edges { + node { + id + } + } + } + } + ` + }) + .then((query): TenantsConnection => query.data?.tenants) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'cannot get tenants page', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.Forbidden + }) + }) + ) + } + }) + }) + + test('can get tenant as operator', async (): Promise => { + const tenant = await createTenant(deps) + + const query = await appContainer.apolloClient + .query({ + query: gql` + query Tenant($id: String!) { + tenant(id: $id) { + id + email + } + } + `, + variables: { + id: tenant.id + } + }) + .then((query): Tenant => query.data?.tenant) + + expect(query).toEqual({ + id: tenant.id, + email: tenant.email, + __typename: 'Tenant' + }) + }) + + test('can get own tenant', async (): Promise => { + const tenant = await createTenant(deps) + const apolloClient = createTenantedApolloClient(appContainer, tenant.id) + + const query = await apolloClient + .query({ + query: gql` + query Tenant($id: String!) { + tenant(id: $id) { + id + email + } + } + `, + variables: { + id: tenant.id + } + }) + .then((query): Tenant => query.data?.tenant) + + expect(query).toEqual({ + id: tenant.id, + email: tenant.email, + __typename: 'Tenant' + }) + }) + + test('cannot get other tenant as non-operator', async (): Promise => { + const firstTenant = await createTenant(deps) + const secondTenant = await createTenant(deps) + + const apolloClient = createTenantedApolloClient( + appContainer, + firstTenant.id + ) + + try { + expect.assertions(2) + await apolloClient + .query({ + query: gql` + query Tenant($id: String!) { + tenant(id: $id) { + id + email + } + } + `, + variables: { + id: secondTenant.id + } + }) + .then((query): Tenant => query.data?.tenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'tenant does not exist', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) + }) + + describe('Mutations', (): void => { + describe('Create', (): void => { + test('can create a tenant', async (): Promise => { + const input = generateTenantInput() + + const mutation = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input + } + }) + .then((query): TenantMutationResponse => query.data?.createTenant) + + expect(mutation.tenant).toEqual({ + ...input, + id: expect.any(String), + __typename: 'Tenant' + }) + }) + + test('cannot create tenant as non-operator', async (): Promise => { + const input = generateTenantInput() + const tenant = await createTenant(deps) + const apolloClient = createTenantedApolloClient(appContainer, tenant.id) + + try { + expect.assertions(2) + await apolloClient + .mutate({ + mutation: gql` + mutation CreateTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input + } + }) + .then((query): TenantMutationResponse => query.data?.createTenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'permission denied', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.Forbidden + }) + }) + ) + } + }) + }) + describe('Update', (): void => { + let tenantedApolloClient: ApolloClient + let tenant: TenantModel + beforeEach(async (): Promise => { + tenant = await createTenant(deps) + tenantedApolloClient = createTenantedApolloClient( + appContainer, + tenant.id + ) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + test.each` + isOperator | description + ${true} | ${'operator'} + ${false} | ${'tenant'} + `( + 'can update a tenant as $description', + async ({ isOperator }): Promise => { + const client = isOperator + ? appContainer.apolloClient + : tenantedApolloClient + const updateInput = { + ...generateTenantInput(), + id: tenant.id + } + + const mutation = await client + .mutate({ + mutation: gql` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: updateInput + } + }) + .then((query): TenantMutationResponse => query.data?.updateTenant) + + expect(mutation.tenant).toEqual({ + ...updateInput, + __typename: 'Tenant' + }) + } + ) + test('Cannot update other tenant as non-operator', async (): Promise => { + const firstTenant = await createTenant(deps) + const secondTenant = await createTenant(deps) + + const updateInput = { + ...generateTenantInput(), + id: secondTenant.id + } + const client = createTenantedApolloClient(appContainer, firstTenant.id) + try { + expect.assertions(2) + await client + .mutate({ + mutation: gql` + mutation UpdateTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + email + apiSecret + idpConsentUrl + idpSecret + publicName + } + } + } + `, + variables: { + input: updateInput + } + }) + .then((query): TenantMutationResponse => query.data?.updateTenant) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'tenant does not exist', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) + }) + + describe('Delete', (): void => { + test('Can delete a tenant as operator', async (): Promise => { + const tenant = await createTenant(deps) + + const mutation = await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation DeleteTenant($id: String!) { + deleteTenant(id: $id) { + success + } + } + `, + variables: { + id: tenant.id + } + }) + .then( + (query): DeleteTenantMutationResponse => query.data?.deleteTenant + ) + + expect(mutation.success).toBe(true) + }) + + test('Cannot delete tenant as non-operator', async (): Promise => { + const firstTenant = await createTenant(deps) + const secondTenant = await createTenant(deps) + + const client = createTenantedApolloClient(appContainer, secondTenant.id) + + try { + expect.assertions(2) + await client + .mutate({ + mutation: gql` + mutation DeleteTenant($id: String!) { + deleteTenant(id: $id) { + success + } + } + `, + variables: { + id: firstTenant.id + } + }) + .then( + (query): DeleteTenantMutationResponse => query.data?.deleteTenant + ) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'permission denied', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.Forbidden + }) + }) + ) + } + }) + }) + }) +}) diff --git a/packages/backend/src/graphql/resolvers/tenant.ts b/packages/backend/src/graphql/resolvers/tenant.ts new file mode 100644 index 0000000000..6fdddf16da --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant.ts @@ -0,0 +1,179 @@ +import { GraphQLError } from 'graphql' +import { TenantedApolloContext } from '../../app' +import { + MutationResolvers, + QueryResolvers, + ResolversTypes, + Tenant as SchemaTenant +} from '../generated/graphql' +import { GraphQLErrorCode } from '../errors' +import { Tenant } from '../../tenants/model' +import { Pagination, SortOrder } from '../../shared/baseModel' +import { getPageInfo } from '../../shared/pagination' + +export const whoami: QueryResolvers['whoami'] = async ( + parent, + args, + ctx +): Promise => { + const { tenant, isOperator } = ctx + + return { + id: tenant.id, + isOperator + } +} + +export const getTenant: QueryResolvers['tenant'] = + async (parent, args, ctx): Promise => { + const { tenant: contextTenant, isOperator } = ctx + + // TODO: make this a util + // If the tenant that was authorized in the request is not the tenant being requested, + // or the requester is not the operator, return not found + if (args.id !== contextTenant.id && !isOperator) { + throw new GraphQLError('tenant does not exist', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.get(args.id, isOperator) + if (!tenant) { + throw new GraphQLError('tenant does not exist', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + + return tenantToGraphQl(tenant) + } + +export const getTenants: QueryResolvers['tenants'] = + async (parent, args, ctx): Promise => { + const { isOperator } = ctx + if (!isOperator) { + throw new GraphQLError('cannot get tenants page', { + extensions: { + code: GraphQLErrorCode.Forbidden + } + }) + } + + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const tenantService = await ctx.container.use('tenantService') + + const tenants = await tenantService.getPage(pagination, order) + + const pageInfo = await getPageInfo({ + getPage: (pagination: Pagination, sortOrder?: SortOrder) => + tenantService.getPage(pagination, sortOrder), + page: tenants, + sortOrder: order + }) + return { + pageInfo, + edges: tenants.map((tenant: Tenant) => ({ + cursor: tenant.id, + node: tenantToGraphQl(tenant) + })) + } + } + +export const createTenant: MutationResolvers['createTenant'] = + async ( + parent, + args, + ctx + ): Promise => { + // createTenant is an operator-only resolver + const { isOperator } = ctx + if (!isOperator) { + throw new GraphQLError('permission denied', { + extensions: { + code: GraphQLErrorCode.Forbidden + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.create(args.input) + + return { tenant: tenantToGraphQl(tenant) } + } + +export const updateTenant: MutationResolvers['updateTenant'] = + async ( + parent, + args, + ctx + ): Promise => { + const { tenant: contextTenant, isOperator } = ctx + // TODO: make this a util + if (args.input.id !== contextTenant.id && !isOperator) { + throw new GraphQLError('tenant does not exist', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + try { + const updatedTenant = await tenantService.update(args.input) + return { tenant: tenantToGraphQl(updatedTenant) } + } catch (err) { + throw new GraphQLError('failed to update tenant', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + } + +export const deleteTenant: MutationResolvers['deleteTenant'] = + async ( + parent, + args, + ctx + ): Promise => { + const { isOperator } = ctx + if (!isOperator) { + throw new GraphQLError('permission denied', { + extensions: { + code: GraphQLErrorCode.Forbidden + } + }) + } + + const tenantService = await ctx.container.use('tenantService') + try { + await tenantService.delete(args.id) + return { success: true } + } catch (err) { + throw new GraphQLError('failed to delete tenant', { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + } + +export function tenantToGraphQl(tenant: Tenant): SchemaTenant { + return { + id: tenant.id, + email: tenant.email, + apiSecret: tenant.apiSecret, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret, + publicName: tenant.publicName, + createdAt: new Date(+tenant.createdAt).toISOString(), + deletedAt: tenant.deletedAt + ? new Date(+tenant.deletedAt).toISOString() + : null + } +} diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.test.ts b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts new file mode 100644 index 0000000000..c4afbd6732 --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant_settings.test.ts @@ -0,0 +1,125 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { createTestApp, TestContainer } from '../../tests/app' +import { initIocContainer } from '../..' +import { Config } from '../../config/app' +import { truncateTables } from '../../tests/tableManager' +import { createTenant } from '../../tests/tenant' +import { + CreateTenantSettingsInput, + CreateTenantSettingsMutationResponse +} from '../generated/graphql' +import { + ApolloClient, + NormalizedCacheObject, + createHttpLink, + ApolloLink, + InMemoryCache, + gql +} from '@apollo/client' +import { setContext } from '@apollo/client/link/context' +import { TenantSettingKeys } from '../../tenants/settings/model' + +function createTenantedApolloClient( + appContainer: TestContainer, + tenantId: string +): ApolloClient { + const httpLink = createHttpLink({ + uri: `http://localhost:${appContainer.app.getAdminPort()}/graphql`, + fetch + }) + const authLink = setContext((_, { headers }) => { + return { + headers: { + ...headers, + 'tenant-id': tenantId + } + } + }) + + const link = ApolloLink.from([authLink, httpLink]) + + return new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) +} + +describe('Tenant Settings Resolvers', (): void => { + let deps: IocContract + let appContainer: TestContainer + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + dbSchema: 'tenant_settings_service_test_schema' + }) + appContainer = await createTestApp(deps) + + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex, true) + }) + + afterAll(async (): Promise => { + await appContainer.apolloClient.stop() + await appContainer.shutdown() + }) + + 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' } + ] + } + + const tenant = await createTenant(deps) + const client = createTenantedApolloClient(appContainer, tenant.id) + const response = await client + .mutate({ + mutation: gql` + mutation CreateTenantSettings($input: CreateTenantSettingsInput!) { + createTenantSettings(input: $input) { + settings { + key + value + } + } + } + `, + variables: { input } + }) + .then((query): CreateTenantSettingsMutationResponse => { + if (query.data) { + return query.data.createTenantSettings + } + throw new Error('Data was empty') + }) + + expect(response.settings.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/packages/backend/src/graphql/resolvers/tenant_settings.ts b/packages/backend/src/graphql/resolvers/tenant_settings.ts new file mode 100644 index 0000000000..37dfa2565b --- /dev/null +++ b/packages/backend/src/graphql/resolvers/tenant_settings.ts @@ -0,0 +1,73 @@ +import { TenantedApolloContext } from '../../app' +import { Pagination } from '../../shared/baseModel' +import { getPageInfo } from '../../shared/pagination' +import { TenantSetting } from '../../tenants/settings/model' +import { + ResolversTypes, + SortOrder, + TenantResolvers, + TenantSetting as SchemaTenantSetting, + MutationResolvers +} from '../generated/graphql' + +export const getTenantSettings: TenantResolvers['settings'] = + async ( + parent, + args, + ctx + ): Promise => { + if (!parent.id) { + throw new Error('missing tenant id') + } + + const tenantSettingsService = await ctx.container.use( + 'tenantSettingService' + ) + + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + + const tenantSettings = await tenantSettingsService.getPage( + parent.id, + pagination, + order + ) + const pageInfo = await getPageInfo({ + getPage: (pagination_?: Pagination, sortOrder_?: SortOrder) => + tenantSettingsService.getPage(parent.id!, pagination_, sortOrder_), + page: tenantSettings + }) + + return { + pageInfo, + edges: tenantSettings.map((ts: TenantSetting) => ({ + cursor: ts.id, + node: tenantSettingsToGraphql(ts) + })) + } + } + +export const createTenantSettings: MutationResolvers['createTenantSettings'] = + async ( + parent, + args, + ctx + ): Promise => { + const tenantSettingService = await ctx.container.use('tenantSettingService') + + const tenantSettings = await tenantSettingService.create({ + tenantId: ctx.tenant.id, + setting: args.input.settings + }) + + return { + settings: tenantSettings.map((x) => tenantSettingsToGraphql(x)) + } + } + +export const tenantSettingsToGraphql = ( + tenantSetting: TenantSetting +): SchemaTenantSetting => ({ + key: tenantSetting.key, + value: tenantSetting.value +}) diff --git a/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts b/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts index fb2f133e0d..fc4400f9af 100644 --- a/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts +++ b/packages/backend/src/graphql/resolvers/walletAddressKey.test.ts @@ -51,7 +51,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('Create Wallet Address Keys', (): void => { test('Can create wallet address key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input: CreateWalletAddressKeyInput = { walletAddressId: walletAddress.id, @@ -104,9 +106,10 @@ describe('Wallet Address Key Resolvers', (): void => { revoked: false }) }) - test('Cannot add duplicate key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input: CreateWalletAddressKeyInput = { walletAddressId: walletAddress.id, @@ -172,7 +175,9 @@ describe('Wallet Address Key Resolvers', (): void => { throw new Error('unexpected') }) - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const input = { walletAddressId: walletAddress.id, @@ -230,7 +235,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('Revoke key', (): void => { test('Can revoke a key', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const key = await walletAddressKeyService.create({ walletAddressId: walletAddress.id, @@ -334,7 +341,9 @@ describe('Wallet Address Key Resolvers', (): void => { describe('List Wallet Address Keys', (): void => { let walletAddressId: string beforeEach(async (): Promise => { - walletAddressId = (await createWalletAddress(deps)).id + walletAddressId = ( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ).id }) getPageTests({ getClient: () => appContainer.apolloClient, diff --git a/packages/backend/src/graphql/resolvers/wallet_address.test.ts b/packages/backend/src/graphql/resolvers/wallet_address.test.ts index 8f559d8a40..88ae1d78d8 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.test.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.test.ts @@ -35,21 +35,27 @@ import { import { getPageTests } from './page.test' import { WalletAddressAdditionalProperty } from '../../open_payments/wallet_address/additional_property/model' import { GraphQLErrorCode } from '../errors' +import { AssetService } from '../../asset/service' +import { faker } from '@faker-js/faker' +import { Tenant } from '../../tenants/model' describe('Wallet Address Resolvers', (): void => { let deps: IocContract let appContainer: TestContainer let knex: Knex let walletAddressService: WalletAddressService + let assetService: AssetService beforeAll(async (): Promise => { - deps = await initIocContainer({ + deps = initIocContainer({ ...Config, - localCacheDuration: 0 + localCacheDuration: 0, + adminApiSecret: '123' //to force not being an operator. }) appContainer = await createTestApp(deps) knex = appContainer.knex walletAddressService = await deps.use('walletAddressService') + assetService = await deps.use('assetService') }) afterEach(async (): Promise => { @@ -69,6 +75,7 @@ describe('Wallet Address Resolvers', (): void => { asset = await createAsset(deps) input = { assetId: asset.id, + tenantId: Config.operatorTenantId, url: 'https://alice.me/.well-known/pay' } }) @@ -306,13 +313,64 @@ describe('Wallet Address Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant create', async (): Promise => { + const badInputData = { + tenantId: 'ae4950b6-3e1b-4e50-ad24-25c065bdd3a9', + assetId: input.assetId, + url: input.url + } + try { + expect.assertions(2) + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation CreateWalletAddress( + $badInputData: CreateWalletAddressInput! + ) { + createWalletAddress(input: $badInputData) { + walletAddress { + id + asset { + code + scale + } + } + } + } + `, + variables: { + badInputData + } + }) + .then((query): CreateWalletAddressMutationResponse => { + if (query.data) { + return query.data.createWalletAddress + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Assignment to the specified tenant is not permitted', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.BadUserInput + }) + }) + ) + } + }) }) describe('Update Wallet Address', (): void => { let walletAddress: WalletAddressModel beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) test('Can update a wallet address', async (): Promise => { @@ -426,6 +484,7 @@ describe('Wallet Address Resolvers', (): void => { }) test('New additional properties override previous additional properties', async (): Promise => { const createOptions = { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'existingKey', @@ -492,6 +551,7 @@ describe('Wallet Address Resolvers', (): void => { }) test('Updating with empty additional properties deletes existing', async (): Promise => { const createOptions = { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'existingKey', @@ -634,6 +694,68 @@ describe('Wallet Address Resolvers', (): void => { ) } }) + + test('bad input data when not allowed to perform cross tenant update', async (): Promise => { + expect.assertions(2) + try { + const tenantOptions = { + apiSecret: 'test-api-secret-new', + publicName: 'test tenant new', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-new' + } + const newTenant = await Tenant.query(knex).insertAndFetch(tenantOptions) + const newAsset = await assetService.create({ + code: 'USD', + scale: 2, + tenantId: newTenant!.id + }) + const newWalletAddress = await walletAddressService.create({ + assetId: (newAsset as Asset).id, + tenantId: newTenant!.id, + url: 'https://alice.me/.well-known/pay-2' + }) + const id = (newWalletAddress as WalletAddressModel).id + + await appContainer.apolloClient + .mutate({ + mutation: gql` + mutation UpdateWalletAddress($input: UpdateWalletAddressInput!) { + updateWalletAddress(input: $input) { + walletAddress { + id + status + } + } + } + `, + variables: { + input: { + id, + status: WalletAddressStatus.Inactive + } + } + }) + .then((query): UpdateWalletAddressMutationResponse => { + if (query.data) { + return query.data.updateWalletAddress + } else { + throw new Error('Data was empty') + } + }) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: 'Unknown wallet address', + extensions: expect.objectContaining({ + code: GraphQLErrorCode.NotFound + }) + }) + ) + } + }) }) describe('Wallet Address Queries', (): void => { @@ -655,6 +777,7 @@ describe('Wallet Address Resolvers', (): void => { const additionalProperties = [walletProp01, walletProp02] const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName, createLiquidityAccount: true, additionalProperties @@ -729,6 +852,7 @@ describe('Wallet Address Resolvers', (): void => { 'Can get a wallet address by its url (publicName: $publicName)', async ({ publicName }): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName, createLiquidityAccount: true }) @@ -818,14 +942,17 @@ describe('Wallet Address Resolvers', (): void => { getPageTests({ getClient: () => appContainer.apolloClient, - createModel: () => createWalletAddress(deps), + createModel: () => + createWalletAddress(deps, { tenantId: Config.operatorTenantId }), pagedQuery: 'walletAddresses' }) test('Can get page of wallet addresses', async (): Promise => { const walletAddresses: WalletAddressModel[] = [] for (let i = 0; i < 2; i++) { - walletAddresses.push(await createWalletAddress(deps)) + walletAddresses.push( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ) } walletAddresses.reverse() // Calling the default getPage will result in descending order const query = await appContainer.apolloClient @@ -874,6 +1001,64 @@ describe('Wallet Address Resolvers', (): void => { }) }) }) + + test('Can get page of wallet addresses with tenantId param', async (): Promise => { + const walletAddresses: WalletAddressModel[] = [] + for (let i = 0; i < 2; i++) { + walletAddresses.push( + await createWalletAddress(deps, { tenantId: Config.operatorTenantId }) + ) + } + walletAddresses.reverse() // Calling the default getPage will result in descending order + const query = await appContainer.apolloClient + .query({ + query: gql` + query WalletAddresses($tenantId: String) { + walletAddresses(tenantId: $tenantId) { + edges { + node { + id + asset { + code + scale + } + url + publicName + } + cursor + } + } + } + `, + variables: { + tenantId: Config.operatorTenantId + } + }) + .then((query): WalletAddressesConnection => { + if (query.data) { + return query.data.walletAddresses + } else { + throw new Error('Data was empty') + } + }) + + expect(query.edges).toHaveLength(2) + query.edges.forEach((edge, idx) => { + const walletAddress = walletAddresses[idx] + expect(edge.cursor).toEqual(walletAddress.id) + expect(edge.node).toEqual({ + __typename: 'WalletAddress', + id: walletAddress.id, + asset: { + __typename: 'Asset', + code: walletAddress.asset.code, + scale: walletAddress.asset.scale + }, + url: walletAddress.url, + publicName: walletAddress.publicName + }) + }) + }) }) describe('Trigger Wallet Address Events', (): void => { @@ -889,6 +1074,7 @@ describe('Wallet Address Resolvers', (): void => { const withdrawalAmount = BigInt(10) for (let i = 0; i < 3; i++) { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) if (i) { diff --git a/packages/backend/src/graphql/resolvers/wallet_address.ts b/packages/backend/src/graphql/resolvers/wallet_address.ts index d1f7172dab..d4766b521d 100644 --- a/packages/backend/src/graphql/resolvers/wallet_address.ts +++ b/packages/backend/src/graphql/resolvers/wallet_address.ts @@ -8,7 +8,7 @@ import { MutationResolvers, WalletAddressStatus } from '../generated/graphql' -import { ApolloContext } from '../../app' +import { ForTenantIdContext, TenantedApolloContext } from '../../app' import { WalletAddressError, isWalletAddressError, @@ -23,19 +23,22 @@ import { CreateOptions, UpdateOptions } from '../../open_payments/wallet_address/service' +import { GraphQLErrorCode } from '../errors' -export const getWalletAddresses: QueryResolvers['walletAddresses'] = +export const getWalletAddresses: QueryResolvers['walletAddresses'] = async ( parent, args, ctx ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const { sortOrder, ...pagination } = args + const { tenantId, sortOrder, ...pagination } = args const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const walletAddresses = await walletAddressService.getPage( pagination, - order + order, + ctx.isOperator ? tenantId : ctx.tenant.id ) const pageInfo = await getPageInfo({ getPage: (pagination: Pagination, sortOrder?: SortOrder) => @@ -52,10 +55,13 @@ export const getWalletAddresses: QueryResolvers['walletAddresses' } } -export const getWalletAddress: QueryResolvers['walletAddress'] = +export const getWalletAddress: QueryResolvers['walletAddress'] = async (parent, args, ctx): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const walletAddress = await walletAddressService.get(args.id) + const walletAddress = await walletAddressService.get( + args.id, + ctx.isOperator ? undefined : ctx.tenant.id + ) if (!walletAddress) { throw new GraphQLError( errorToMessage[WalletAddressError.UnknownWalletAddress], @@ -69,18 +75,21 @@ export const getWalletAddress: QueryResolvers['walletAddress'] = return walletAddressToGraphql(walletAddress) } -export const getWalletAddressByUrl: QueryResolvers['walletAddressByUrl'] = +export const getWalletAddressByUrl: QueryResolvers['walletAddressByUrl'] = async ( parent, args, ctx ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') - const walletAddress = await walletAddressService.getByUrl(args.url) + const walletAddress = await walletAddressService.getByUrl( + args.url, + ctx.isOperator ? undefined : ctx.tenant.id + ) return walletAddress ? walletAddressToGraphql(walletAddress) : null } -export const createWalletAddress: MutationResolvers['createWalletAddress'] = +export const createWalletAddress: MutationResolvers['createWalletAddress'] = async ( parent, args, @@ -97,8 +106,20 @@ export const createWalletAddress: MutationResolvers['createWallet addProps.push(toAdd) }) + const tenantId = ctx.forTenantId + if (!tenantId) + throw new GraphQLError( + `Assignment to the specified tenant is not permitted`, + { + extensions: { + code: GraphQLErrorCode.BadUserInput + } + } + ) + const options: CreateOptions = { assetId: args.input.assetId, + tenantId, additionalProperties: addProps, publicName: args.input.publicName, url: args.input.url @@ -117,7 +138,7 @@ export const createWalletAddress: MutationResolvers['createWallet } } -export const updateWalletAddress: MutationResolvers['updateWalletAddress'] = +export const updateWalletAddress: MutationResolvers['updateWalletAddress'] = async ( parent, args, @@ -125,9 +146,23 @@ export const updateWalletAddress: MutationResolvers['updateWallet ): Promise => { const walletAddressService = await ctx.container.use('walletAddressService') const { additionalProperties, ...rest } = args.input + const updateOptions: UpdateOptions = { ...rest } + + const existing = await walletAddressService.get( + updateOptions.id, + ctx.forTenantId + ) + if (!existing) { + throw new GraphQLError(`Unknown wallet address`, { + extensions: { + code: GraphQLErrorCode.NotFound + } + }) + } + if (additionalProperties) { updateOptions.additionalProperties = additionalProperties.map( (property) => { @@ -153,7 +188,7 @@ export const updateWalletAddress: MutationResolvers['updateWallet } } -export const triggerWalletAddressEvents: MutationResolvers['triggerWalletAddressEvents'] = +export const triggerWalletAddressEvents: MutationResolvers['triggerWalletAddressEvents'] = async ( parent, args, @@ -166,15 +201,18 @@ export const triggerWalletAddressEvents: MutationResolvers['trigg } } -export const walletAddressToGraphql = ( +export function walletAddressToGraphql( walletAddress: WalletAddress -): SchemaWalletAddress => ({ - id: walletAddress.id, - url: walletAddress.url, - asset: assetToGraphql(walletAddress.asset), - publicName: walletAddress.publicName ?? undefined, - createdAt: new Date(+walletAddress.createdAt).toISOString(), - status: walletAddress.isActive - ? WalletAddressStatus.Active - : WalletAddressStatus.Inactive -}) +): SchemaWalletAddress { + return { + id: walletAddress.id, + url: walletAddress.url, + asset: assetToGraphql(walletAddress.asset), + publicName: walletAddress.publicName ?? undefined, + createdAt: new Date(+walletAddress.createdAt).toISOString(), + status: walletAddress.isActive + ? WalletAddressStatus.Active + : WalletAddressStatus.Inactive, + tenantId: walletAddress.tenantId + } +} diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index f8286e14d4..62f1616e68 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -16,7 +16,7 @@ type Query { after: String "Backward pagination: Cursor (asset ID) to start retrieving assets before this point." before: String - "Foward pagination: Limit the result to the first **n** assets after the `after` cursor." + "Forward pagination: Limit the result to the first **n** assets after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** assets before the `before` cursor." last: Int @@ -70,6 +70,8 @@ type Query { last: Int "Specify the sort order of wallet addresses based on their creation date, either ascending or descending." sortOrder: SortOrder + "Unique identifier of the tenant associated with the wallet address. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: String ): WalletAddressesConnection! "Fetch an Open Payments quote by its ID." @@ -95,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." @@ -133,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." @@ -148,6 +154,26 @@ type Query { "Unique identifier of the receiver (incoming payment URL)." id: String! ): Receiver + + "Retrieve a tenant of the instance." + tenant("Unique identifier of the tenant." id: String!): Tenant! + + "As an operator, fetch a paginated list of tenants on the instance." + tenants( + "Forward pagination: Cursor (tenant ID) to start retrieving tenants after this point." + after: String + "Backward pagination: Cursor (tenant ID) to start retrieving tenants before this point." + before: String + "Forward pagination: Limit the result to the first **n** tenants after the `after` cursor." + first: Int + "Backward pagination: Limit the result to the last **n** tenants before the `before` cursor." + last: Int + "Specify the sort order of tenants based on their creation date, either ascending or descending." + sortOrder: SortOrder + ): TenantsConnection! + + "Determine if the requester has operator permissions" + whoami: WhoamiResponse! } type Mutation { @@ -219,6 +245,10 @@ type Mutation { input: CreateWalletAddressKeyInput! ): CreateWalletAddressKeyMutationResponse + createTenantSettings( + input: CreateTenantSettingsInput! + ): CreateTenantSettingsMutationResponse + "Revoke a public key associated with a wallet address. Open Payment requests using this key for request signatures will be denied going forward." revokeWalletAddressKey( input: RevokeWalletAddressKeyInput! @@ -306,6 +336,15 @@ type Mutation { cancelIncomingPayment( input: CancelIncomingPaymentInput! ): CancelIncomingPaymentResponse! + + "As an operator, create a tenant." + createTenant(input: CreateTenantInput!): TenantMutationResponse! + + "Update a tenant." + updateTenant(input: UpdateTenantInput!): TenantMutationResponse! + + "Delete a tenant." + deleteTenant(id: String!): DeleteTenantMutationResponse! } type PageInfo { @@ -319,6 +358,11 @@ type PageInfo { startCursor: String } +type WhoamiResponse { + id: String! + isOperator: Boolean! +} + type AssetsConnection { "Information to aid in pagination." pageInfo: PageInfo! @@ -354,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 { @@ -594,6 +640,18 @@ input CreateWalletAddressKeyInput { idempotencyKey: String } +input CreateTenantSettingsInput { + "List of a settings for a tenant." + settings: [TenantSettingInput!]! +} + +input TenantSettingInput { + "Key for this setting." + key: String! + "Value of a setting for this key." + value: String! +} + input RevokeWalletAddressKeyInput { "Internal unique identifier of the key to revoke." id: String! @@ -624,7 +682,7 @@ type Asset implements Model { after: String "Backward pagination: Cursor (fee ID) to start retrieving fees before this point." before: String - "Foward pagination: Limit the result to the first **n** fees after the `after` cursor." + "Forward pagination: Limit the result to the first **n** fees after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** fees before the `before` cursor." last: Int @@ -633,6 +691,7 @@ type Asset implements Model { ): FeesConnection "The date and time when the asset was created." createdAt: String! + tenantId: ID! } enum SortOrder { @@ -759,7 +818,7 @@ type WalletAddress implements Model { after: String "Backward pagination: Cursor (quote ID) to start retrieving quotes before this point." before: String - "Foward pagination: Limit the result to the first **n** quotes after the `after` cursor." + "Forward pagination: Limit the result to the first **n** quotes after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** quotes before the `before` cursor." last: Int @@ -793,7 +852,7 @@ type WalletAddress implements Model { after: String "Backward pagination: Cursor (wallet address key ID) to start retrieving keys before this point." before: String - "Foward pagination: Limit the result to the first **n** keys after the `after` cursor." + "Forward pagination: Limit the result to the first **n** keys after the `after` cursor." first: Int "Backward pagination: Limit the result to the last **n** keys before the `before` cursor." last: Int @@ -803,6 +862,9 @@ type WalletAddress implements Model { "Additional properties associated with the wallet address." additionalProperties: [AdditionalProperty] + + "Tenant ID of the wallet address." + tenantId: String } type AdditionalProperty { @@ -883,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 { @@ -980,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 { @@ -1103,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." @@ -1226,6 +1294,8 @@ type CreateReceiverResponse { } input CreateWalletAddressInput { + "Unique identifier of the tenant associated with the wallet address. This cannot be changed. Optional, if not provided, the tenantId will be obtained from the signature." + tenantId: ID "Unique identifier of the asset associated with the wallet address. This cannot be changed." assetId: String! "Wallet address URL. This cannot be changed." @@ -1473,6 +1543,11 @@ type CreateWalletAddressKeyMutationResponse { walletAddressKey: WalletAddressKey } +type CreateTenantSettingsMutationResponse { + "New tenant settings." + settings: [TenantSetting!]! +} + type RevokeWalletAddressKeyMutationResponse { "The wallet address key that was revoked." walletAddressKey: WalletAddressKey @@ -1493,6 +1568,111 @@ type CancelIncomingPaymentResponse { payment: IncomingPayment } +type Tenant implements Model { + "Unique identifier of the tenant." + id: ID! + "Contact email of the tenant owner." + email: String + "Secret used to secure requests made for this tenant." + apiSecret: String! + "URL of the tenant's identity provider's consent screen." + idpConsentUrl: String + "Secret used to secure requests from the tenant's identity provider." + idpSecret: String + "Public name for the tenant." + publicName: String + "The date and time that this tenant was created." + createdAt: String! + "The date and time that this tenant was deleted." + deletedAt: String + "List of settings for the tenant." + settings( + "Forward pagination: Cursor (wallet address key ID) to start retrieving settings after this point." + after: String + "Backward pagination: Cursor (wallet address key ID) to start retrieving keys before this point." + before: String + "Forward pagination: Limit the result to the first **n** keys after the `after` cursor." + first: Int + "Backward pagination: Limit the result to the last **n** keys before the `before` cursor." + last: Int + "Specify the sort order of keys based on their creation data, either ascending or descending." + sortOrder: SortOrder + ): TenantSettingsConnection +} + +type TenantSettingsConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges representing tenant settings and cursors for pagination." + edges: [TenantSettingEdge!]! +} + +type TenantSettingEdge { + "A tenant setting node in the list." + node: TenantSetting! + "A cursor for paginating through the tenants." + cursor: String! +} + +type TenantsConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges representing tenants and cursors for pagination." + edges: [TenantEdge!]! +} + +type TenantEdge { + "A tenant node in the list." + node: Tenant! + "A cursor for paginating through the tenants." + cursor: String! +} + +type TenantSetting { + "Key for this setting." + key: String! + "Value of a setting for this key." + value: String! +} + +input CreateTenantInput { + "Contact email of the tenant owner." + email: String + "Secret used to secure requests made for this tenant." + apiSecret: String! + "URL of the tenant's identity provider's consent screen." + idpConsentUrl: String + "Secret used to secure requests from the tenant's identity provider." + idpSecret: String + "Public name for the tenant." + publicName: String + "Initial settings for tenant." + settings: [TenantSettingInput!] +} + +input UpdateTenantInput { + "Unique identifier of the tenant." + id: ID! + "Contact email of the tenant owner." + email: String + "Secret used to secure requests made for this tenant." + apiSecret: String + "URL of the tenant's identity provider's consent screen." + idpConsentUrl: String + "Secret used to secure requests from the tenant's identity provider." + idpSecret: String + "Public name for the tenant." + publicName: String +} + +type TenantMutationResponse { + tenant: Tenant! +} + +type DeleteTenantMutationResponse { + success: Boolean! +} + """ The `UInt8` scalar type represents unsigned 8-bit whole numeric values, ranging from 0 to 255. """ diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 91db346566..a99d0b10b3 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,6 +7,7 @@ import { createClient } from 'tigerbeetle-node' import { createClient as createIntrospectionClient } from 'token-introspection' import net from 'net' import dns from 'dns' +import { createHmac } from 'crypto' import { createAuthenticatedClient as createOpenPaymentsClient, @@ -15,6 +16,17 @@ import { } from '@interledger/open-payments' import { StreamServer } from '@interledger/stream-receiver' import axios from 'axios' +import { + ApolloClient, + ApolloLink, + createHttpLink, + InMemoryCache +} from '@apollo/client' +import { onError } from '@apollo/client/link/error' +import { setContext } from '@apollo/client/link/context' +import { canonicalize } from 'json-canonicalize' +import { print } from 'graphql/language/printer' + import { createAccountingService as createPsqlAccountingService } from './accounting/psql/service' import { createAccountingService as createTigerbeetleAccountingService } from './accounting/tigerbeetle/service' import { App, AppServices } from './app' @@ -61,6 +73,9 @@ import { } from './telemetry/service' import { createWebhookService } from './webhook/service' import { createInMemoryDataStore } from './middleware/cache/data-stores/in-memory' +import { createTenantService } from './tenants/service' +import { AuthServiceClient } from './auth-service-client/client' +import { createTenantSettingService } from './tenants/settings/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -93,6 +108,7 @@ export function initIocContainer( directory: './', tableName: 'knex_migrations' }, + searchPath: config.dbSchema, log: { warn(message) { logger.warn(message) @@ -114,6 +130,9 @@ export function initIocContainer( 'text', BigInt ) + if (config.dbSchema) { + await db.raw(`CREATE SCHEMA IF NOT EXISTS "${config.dbSchema}"`) + } return db }) container.singleton('redis', async (deps): Promise => { @@ -131,6 +150,104 @@ export function initIocContainer( }) }) + container.singleton('apolloClient', async (deps) => { + const [logger, config] = await Promise.all([ + deps.use('logger'), + deps.use('config') + ]) + + const httpLink = createHttpLink({ + uri: config.authAdminApiUrl + }) + + const errorLink = onError(({ graphQLErrors }) => { + if (graphQLErrors) { + logger.error(graphQLErrors) + graphQLErrors.map(({ extensions }) => { + if (extensions && extensions.code === 'UNAUTHENTICATED') { + logger.error('UNAUTHENTICATED') + } + + if (extensions && extensions.code === 'FORBIDDEN') { + logger.error('FORBIDDEN') + } + }) + } + }) + + const authLink = setContext((request, { headers }) => { + if (!config.authAdminApiSecret || !config.authAdminApiSignatureVersion) + return { headers } + const timestamp = Date.now() + const version = config.authAdminApiSignatureVersion + + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) + } + + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac('sha256', config.authAdminApiSecret) + hmac.update(payload) + const digest = hmac.digest('hex') + + return { + headers: { + ...headers, + signature: `t=${timestamp}, v${version}=${digest}` + } + } + }) + + const link = ApolloLink.from([errorLink, authLink, httpLink]) + + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) + + return client + }) + + container.singleton('tenantCache', async () => { + return createInMemoryDataStore(config.localCacheDuration) + }) + + container.singleton('authServiceClient', () => { + return new AuthServiceClient(config.authServiceApiUrl) + }) + + container.singleton('tenantService', async (deps) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + tenantCache: await deps.use('tenantCache'), + authServiceClient: deps.use('authServiceClient'), + tenantSettingService: await deps.use('tenantSettingService') + }) + }) + + container.singleton('tenantSettingService', async (deps) => { + const [logger, knex] = await Promise.all([ + deps.use('logger'), + deps.use('knex') + ]) + return createTenantSettingService({ logger, knex }) + }) + container.singleton('ratesService', async (deps) => { const config = await deps.use('config') return createRatesService({ @@ -295,7 +412,8 @@ export function initIocContainer( accountingService: await deps.use('accountingService'), webhookService: await deps.use('webhookService'), assetService: await deps.use('assetService'), - walletAddressCache: await deps.use('walletAddressCache') + walletAddressCache: await deps.use('walletAddressCache'), + tenantSettingService: await deps.use('tenantSettingService') }) }) container.singleton('spspRoutes', async (deps) => { diff --git a/packages/backend/src/middleware/tenant/index.test.ts b/packages/backend/src/middleware/tenant/index.test.ts new file mode 100644 index 0000000000..38d1288313 --- /dev/null +++ b/packages/backend/src/middleware/tenant/index.test.ts @@ -0,0 +1,13 @@ +import { tenantIdToProceed } from './index' + +describe('Set For Tenant', (): void => { + test('test tenant id to proceed', async (): Promise => { + const sig = 'sig' + const tenantId = 'tenantId' + expect(tenantIdToProceed(false, sig)).toBe(sig) + expect(tenantIdToProceed(false, sig, tenantId)).toBeUndefined() + expect(tenantIdToProceed(false, sig, sig)).toBe(sig) + expect(tenantIdToProceed(true, sig)).toBe(sig) + expect(tenantIdToProceed(true, sig, tenantId)).toBe(tenantId) + }) +}) diff --git a/packages/backend/src/middleware/tenant/index.ts b/packages/backend/src/middleware/tenant/index.ts new file mode 100644 index 0000000000..a90f75670b --- /dev/null +++ b/packages/backend/src/middleware/tenant/index.ts @@ -0,0 +1,48 @@ +import { ForTenantIdContext, TenantedApolloContext } from '../../app' + +type Request = () => Promise + +interface TenantValidateMiddlewareArgs { + deps: { context: TenantedApolloContext } + tenantIdInput: string | undefined + next: Request +} + +export async function validateTenantMiddleware( + args: TenantValidateMiddlewareArgs +): ReturnType { + const { + deps: { context }, + tenantIdInput, + next + } = args + ;(context as ForTenantIdContext).forTenantId = tenantIdToProceed( + context.isOperator, + context.tenant.id, + tenantIdInput + ) + return next() +} + +/** + * The tenantId to use will be determined as follows: + * - When an operator and the {tenantId} is present, return {tenantId} + * - When an operator and {tenantId} is not present, return {signatureTenantId} + * - When NOT an operator and {tenantId} is present, but does not match {signatureTenantId}, return {undefined} + * - Otherwise return {signatureTenantId} + * + * @param isOperator is operator + * @param signatureTenantId the signature tenantId + * @param tenantId the intended tenantId + */ +export function tenantIdToProceed( + isOperator: boolean, + signatureTenantId: string, + tenantId?: string +): string | undefined { + if (isOperator && tenantId) return tenantId + else if (isOperator) return signatureTenantId + return tenantId && tenantId !== signatureTenantId + ? undefined + : signatureTenantId +} diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 26b06dc9da..6f066e4772 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -78,7 +78,9 @@ describe('Auth Middleware', (): void => { Authorization: `GNAP ${token}` } }, - walletAddress: await createWalletAddress(deps) + walletAddress: await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) ctx.container = deps }) diff --git a/packages/backend/src/open_payments/payment/combined/service.test.ts b/packages/backend/src/open_payments/payment/combined/service.test.ts index fddb5efd43..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,15 +38,20 @@ describe('Combined Payment Service', (): void => { appContainer = await createTestApp(deps) knex = appContainer.knex combinedPaymentService = await deps.use('combinedPaymentService') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { sendAsset = await createAsset(deps) receiveAsset = await createAsset(deps) sendWalletAddressId = ( - await createWalletAddress(deps, { assetId: sendAsset.id }) + await createWalletAddress(deps, { + tenantId: sendAsset.tenantId, + assetId: sendAsset.id + }) ).id receiveWalletAddress = await createWalletAddress(deps, { + tenantId: sendAsset.tenantId, assetId: receiveAsset.id }) }) @@ -60,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 e4577af3ad..a71cf23d4b 100644 --- a/packages/backend/src/open_payments/payment/incoming/model.test.ts +++ b/packages/backend/src/open_payments/payment/incoming/model.test.ts @@ -42,18 +42,21 @@ describe('Models', (): void => { let incomingPayment: IncomingPayment beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) baseUrl = new URL(walletAddress.url).origin incomingPayment = await createIncomingPayment(deps, { walletAddressId: walletAddress.id, - 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), @@ -81,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), @@ -106,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), @@ -137,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 72835eae3b..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,6 +56,7 @@ describe('Incoming Payment Routes', (): void => { expiresAt = new Date(Date.now() + 30_000) asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) baseUrl = new URL(walletAddress.url).origin @@ -85,7 +88,8 @@ describe('Incoming Payment Routes', (): void => { client, expiresAt, incomingAmount, - metadata + metadata, + tenantId }), get: (ctx) => incomingPaymentRoutes.get(ctx as ReadContextWithAuthenticatedStatus), @@ -127,9 +131,12 @@ describe('Incoming Payment Routes', (): void => { test.each([IncomingPaymentState.Completed, IncomingPaymentState.Expired])( 'returns incoming payment with empty methods if payment state is %s', async (paymentState): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId }) await incomingPayment.$query().update({ state: paymentState }) @@ -150,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 => { @@ -169,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') @@ -179,7 +226,8 @@ describe('Incoming Payment Routes', (): void => { status: errorToHTTPCode[error] }) expect(createSpy).toHaveBeenCalledWith({ - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId }) } ) @@ -197,6 +245,9 @@ describe('Incoming Payment Routes', (): void => { expiresAt }): Promise => { const ctx = setup>({ + params: { + tenantId + }, reqOpts: { body: { incomingAmount: incomingAmount ? amount : undefined, @@ -217,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 = ( @@ -227,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), @@ -261,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 => { @@ -272,7 +325,8 @@ describe('Incoming Payment Routes', (): void => { url: `/incoming-payments/${incomingPayment.id}/complete` }, params: { - id: incomingPayment.id + id: incomingPayment.id, + tenantId }, walletAddress }) @@ -306,7 +360,8 @@ describe('Incoming Payment Routes', (): void => { walletAddressId: walletAddress.id, expiresAt, incomingAmount, - metadata + metadata, + tenantId }) const ctx = setup({ @@ -316,7 +371,8 @@ describe('Incoming Payment Routes', (): void => { url: `/incoming-payments/${incomingPayment.id}` }, params: { - id: incomingPayment.id + id: incomingPayment.id, + tenantId }, walletAddress }) @@ -325,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 6826f46b00..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,11 +50,15 @@ describe('Incoming Payment Service', (): void => { knex = appContainer.knex incomingPaymentService = await deps.use('incomingPaymentService') config = await deps.use('config') + tenantId = Config.operatorTenantId }) beforeEach(async (): Promise => { asset = await createAsset(deps) - const address = await createWalletAddress(deps, { assetId: asset.id }) + const address = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, + assetId: asset.id + }) walletAddressId = address.id client = address.url }) @@ -101,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({ @@ -171,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 => { @@ -185,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) }) @@ -200,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)) @@ -218,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) @@ -230,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 => { @@ -243,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) }) @@ -257,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)) @@ -275,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) @@ -340,7 +354,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.UnknownWalletAddress) }) @@ -362,7 +377,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) await expect( @@ -377,7 +393,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) }) @@ -395,7 +412,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) await expect( @@ -410,7 +428,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidAmount) }) @@ -428,7 +447,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) @@ -451,7 +471,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InactiveWalletAddress) }) @@ -471,7 +492,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) ).resolves.toBe(IncomingPaymentError.InvalidExpiry) }) @@ -488,7 +510,8 @@ describe('Incoming Payment Service', (): void => { } payment = (await incomingPaymentService.create({ walletAddressId, - incomingAmount: amount + incomingAmount: amount, + tenantId })) as IncomingPayment assert.ok(!isIncomingPaymentError(payment)) }) @@ -502,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)) @@ -516,6 +540,7 @@ describe('Incoming Payment Service', (): void => { await expect( incomingPaymentService.update({ id: uuid(), + tenantId: Config.operatorTenantId, metadata: { description: 'Test incoming payment', externalRef: '#123' @@ -540,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) @@ -562,7 +588,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPaymentOrError)) incomingPayment = incomingPaymentOrError @@ -625,7 +652,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) assert.ok(!isIncomingPaymentError(incomingPaymentOrError)) const incomingPaymentId = incomingPaymentOrError.id @@ -654,7 +682,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) await expect( accountingService.createDeposit({ @@ -691,7 +720,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) jest.useFakeTimers() jest.setSystemTime(incomingPayment.expiresAt) @@ -728,7 +758,8 @@ describe('Incoming Payment Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId }) await expect( accountingService.createDeposit({ @@ -816,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 => { @@ -824,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({ @@ -838,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 => { @@ -861,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, @@ -899,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({ @@ -922,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/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 f30dab4641..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,7 +79,11 @@ describe('Outgoing Payment Routes', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) - walletAddress = await createWalletAddress(deps, { assetId: asset.id }) + tenantId = Config.operatorTenantId + walletAddress = await createWalletAddress(deps, { + tenantId, + assetId: asset.id + }) baseUrl = new URL(walletAddress.url).origin }) @@ -114,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), @@ -134,7 +140,7 @@ describe('Outgoing Payment Routes', (): void => { type SetupContextOptions = UnionOmit< CreateOutgoingPaymentOptions, - 'walletAddressId' + 'walletAddressId' | 'tenantId' > describe('create', (): void => { @@ -149,6 +155,9 @@ describe('Outgoing Payment Routes', (): void => { url: `/outgoing-payments`, body: options }, + params: { + tenantId + }, walletAddress, client: options.client, grant: options.grant @@ -182,6 +191,7 @@ describe('Outgoing Payment Routes', (): void => { CreateOutgoingPaymentBaseOptions, 'walletAddressId' > = { + tenantId, client, grant, metadata @@ -189,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) @@ -212,6 +222,7 @@ describe('Outgoing Payment Routes', (): void => { ).resolves.toBeUndefined() let expectedCreateOptions: CreateOutgoingPaymentBaseOptions = { + tenantId, walletAddressId: walletAddress.id, metadata, client, @@ -240,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: @@ -281,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') @@ -300,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 a87bf31c9c..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,15 +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, assetId: sendAssetId }) walletAddressId = walletAddress.id client = walletAddress.url const { id: destinationAssetId } = await createAsset(deps, destinationAsset) receiverWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -290,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) @@ -327,6 +335,7 @@ describe('OutgoingPaymentService', (): void => { getTests({ createModel: ({ client }) => createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -342,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, @@ -349,6 +359,7 @@ describe('OutgoingPaymentService', (): void => { }) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id, client @@ -363,6 +374,7 @@ describe('OutgoingPaymentService', (): void => { ).resolves.toEqual(payment) await expect( outgoingPaymentService.fund({ + tenantId, id: payment.id, amount: payment.debitAmount.value, transferId: uuid() @@ -386,6 +398,7 @@ describe('OutgoingPaymentService', (): void => { getPageTests({ createModel: () => createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -408,16 +421,22 @@ describe('OutgoingPaymentService', (): void => { let outgoingPayment: OutgoingPayment let otherOutgoingPayment: OutgoingPayment beforeEach(async (): Promise => { - otherSenderWalletAddress = await createWalletAddress(deps, { assetId }) + otherSenderWalletAddress = await createWalletAddress(deps, { + tenantId, + assetId + }) otherReceiverWalletAddress = await createWalletAddress(deps, { + 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, @@ -431,6 +450,7 @@ describe('OutgoingPaymentService', (): void => { }) otherOutgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId: otherSenderWalletAddress.id, client, receiver: otherReceiver, @@ -504,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, @@ -511,6 +532,7 @@ describe('OutgoingPaymentService', (): void => { }) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, client, quoteId: quote.id @@ -526,6 +548,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: payment.debitAmount.value, transferId: uuid() }) @@ -577,6 +600,7 @@ describe('OutgoingPaymentService', (): void => { * 4. Based on state, check the result */ const outgoingPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -593,6 +617,7 @@ describe('OutgoingPaymentService', (): void => { const response = await outgoingPaymentService.cancel({ id: outgoingPayment.id, + tenantId, reason }) @@ -631,6 +656,7 @@ describe('OutgoingPaymentService', (): void => { const quoteSpy = jest.spyOn(quoteService, 'create') const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, debitAmount, incomingPayment: incomingPaymentUrl @@ -638,6 +664,7 @@ describe('OutgoingPaymentService', (): void => { expect(!isOutgoingPaymentError(payment)).toBeTruthy() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId, receiver: incomingPaymentUrl, debitAmount, @@ -671,6 +698,7 @@ describe('OutgoingPaymentService', (): void => { }) const options: CreateOutgoingPaymentOptions = { + tenantId, walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( @@ -716,6 +744,7 @@ describe('OutgoingPaymentService', (): void => { }) const options: CreateOutgoingPaymentOptions = { + tenantId, walletAddressId: receiverWalletAddress.id, debitAmount, incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( @@ -753,6 +782,7 @@ describe('OutgoingPaymentService', (): void => { .mockImplementationOnce(async () => quoteCreateResponse) const payment = await outgoingPaymentService.create({ + tenantId, walletAddressId, debitAmount, incomingPayment: incomingPaymentUrl @@ -761,6 +791,7 @@ describe('OutgoingPaymentService', (): void => { expect(isOutgoingPaymentError(payment)).toBeTruthy() expect(payment).toBe(quoteCreateResponse) expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId, receiver: incomingPaymentUrl, debitAmount, @@ -802,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, @@ -865,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({ + 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({ - walletAddressId: uuid(), + 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() }) @@ -890,6 +959,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on "consumed" quote', async () => { const { quote } = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver, @@ -898,6 +968,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, client, quoteId: quote.id @@ -907,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, @@ -915,6 +987,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId: receiverWalletAddress.id, quoteId: quote.id }) @@ -923,6 +996,7 @@ describe('OutgoingPaymentService', (): void => { it('fails to create on expired quote', async () => { const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -934,6 +1008,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id }) @@ -947,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, @@ -959,6 +1035,7 @@ describe('OutgoingPaymentService', (): void => { }) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId, quoteId: quote.id }) @@ -968,19 +1045,23 @@ describe('OutgoingPaymentService', (): void => { test('fails to create on inactive wallet address', async () => { const { id: quoteId } = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, validDestination: false, method: 'ilp' }) - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId + }) const walletAddressUpdated = await WalletAddress.query( knex ).patchAndFetchById(walletAddress.id, { deactivatedAt: new Date() }) assert.ok(!walletAddressUpdated.isActive) await expect( outgoingPaymentService.create({ + tenantId, walletAddressId: walletAddress.id, quoteId }) @@ -997,6 +1078,7 @@ describe('OutgoingPaymentService', (): void => { const quotes = await Promise.all( [0, 1].map(async (_) => { return await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1006,6 +1088,7 @@ describe('OutgoingPaymentService', (): void => { ) const options = quotes.map((quote) => { return { + tenantId, walletAddressId, client, quoteId: quote.id, @@ -1048,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: { @@ -1083,6 +1168,7 @@ describe('OutgoingPaymentService', (): void => { receiver } const quote = await createQuote(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1183,6 +1269,7 @@ describe('OutgoingPaymentService', (): void => { value: BigInt(190) } const firstPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver: `${ @@ -1270,6 +1357,7 @@ describe('OutgoingPaymentService', (): void => { value: BigInt(7) } const firstPayment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, client, receiver: `${ @@ -1326,6 +1414,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: payment.debitAmount.value, transferId: uuid() }) @@ -1347,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' @@ -1384,6 +1475,7 @@ describe('OutgoingPaymentService', (): void => { .spyOn(telemetryService!, 'incrementCounter') .mockImplementation(() => Promise.resolve()) const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1428,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' @@ -1468,6 +1562,7 @@ describe('OutgoingPaymentService', (): void => { const spyCounter = jest.spyOn(telemetryService, 'incrementCounter') const createdPayment = await setup({ + tenantId, receiver, debitAmount, receiveAmount, @@ -1498,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) @@ -1521,6 +1617,7 @@ describe('OutgoingPaymentService', (): void => { assert.ok(incomingPayment.receivedAmount?.assetScale) const createdPayment = await setup({ + tenantId, receiver: incomingPayment.getUrl(incomingPayment.walletAddress), receiveAmount, method: 'ilp' @@ -1543,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' @@ -1580,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' @@ -1630,6 +1729,7 @@ describe('OutgoingPaymentService', (): void => { test('FAILED (non-retryable error)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1661,6 +1761,7 @@ describe('OutgoingPaymentService', (): void => { test('SENDING→COMPLETED (partial payment, resume, complete)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1692,6 +1793,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (already fully paid)', async (): Promise => { const createdPayment = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1723,6 +1825,7 @@ describe('OutgoingPaymentService', (): void => { test('COMPLETED (already fully paid)', async (): Promise => { const { id: paymentId } = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1748,6 +1851,7 @@ describe('OutgoingPaymentService', (): void => { test('FAILED (source asset changed)', async (): Promise => { const { id: paymentId } = await setup( { + tenantId, receiver, receiveAmount, method: 'ilp' @@ -1772,6 +1876,7 @@ describe('OutgoingPaymentService', (): void => { }) test('FAILED (destination asset changed)', async (): Promise => { const createdPayment = await setup({ + tenantId, receiver, debitAmount, method: 'ilp' @@ -1800,6 +1905,7 @@ describe('OutgoingPaymentService', (): void => { beforeEach(async (): Promise => { payment = await createOutgoingPayment(deps, { + tenantId, walletAddressId, receiver, debitAmount, @@ -1818,6 +1924,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: uuid(), + tenantId, amount: quoteAmount, transferId: uuid() }) @@ -1828,6 +1935,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount, transferId: uuid() }) @@ -1847,6 +1955,7 @@ describe('OutgoingPaymentService', (): void => { await expect( outgoingPaymentService.fund({ id: payment.id, + tenantId, amount: quoteAmount - BigInt(1), transferId: uuid() }) @@ -1866,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 422759bd73..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,11 +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, assetId }) baseUrl = new URL(walletAddress.url).origin @@ -95,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), @@ -129,6 +136,9 @@ describe('Quote Routes', (): void => { method: 'POST', url: `/quotes` }, + params: { + tenantId + }, walletAddress, client }) @@ -194,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 && { @@ -215,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: { @@ -253,6 +264,7 @@ describe('Quote Routes', (): void => { }) await expect(quoteRoutes.create(ctx)).resolves.toBeUndefined() expect(quoteSpy).toHaveBeenCalledWith({ + tenantId, walletAddressId: walletAddress.id, receiver, client, @@ -266,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 3840212c2e..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,19 +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, assetId: sendAssetId }) const { id: destinationAssetId } = await createAsset(deps, destinationAsset) receivingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: destinationAssetId, mockServerPort: appContainer.openPaymentsPort }) @@ -135,6 +142,7 @@ describe('QuoteService', (): void => { getTests({ createModel: ({ client }) => createQuote(deps, { + tenantId, walletAddressId: sendingWalletAddress.id, receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, debitAmount: { @@ -176,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' @@ -254,6 +264,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) @@ -340,6 +351,7 @@ describe('QuoteService', (): void => { await expect( quoteService.get({ + tenantId, id: quote.id }) ).resolves.toEqual(quote) @@ -380,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, @@ -421,26 +435,60 @@ 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) + const walletAddress = await createWalletAddress(deps, { + tenantId + }) const walletAddressUpdated = await WalletAddress.query( knex ).patchAndFetchById(walletAddress.id, { deactivatedAt: new Date() }) assert.ok(!walletAddressUpdated.isActive) await expect( quoteService.create({ + tenantId, walletAddressId: walletAddress.id, receiver: `${receivingWalletAddress.url}/incoming-payments/${uuid()}`, debitAmount, @@ -452,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, @@ -474,6 +523,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp', @@ -499,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' @@ -525,9 +577,11 @@ describe('QuoteService', (): void => { scale: 2 }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) }) @@ -551,7 +605,8 @@ describe('QuoteService', (): void => { assetCode: asset.code, assetScale: asset.scale, value: incomingAmountValue - } + }, + tenantId: Config.operatorTenantId }) await Fee.query().insertAndFetch({ @@ -572,6 +627,7 @@ describe('QuoteService', (): void => { .mockResolvedValueOnce(mockedQuote) const quote = await quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp' @@ -593,7 +649,8 @@ describe('QuoteService', (): void => { assetCode: asset.code, assetScale: asset.scale, value: incomingAmountValue - } + }, + tenantId: Config.operatorTenantId }) const mockedQuote = mockQuote({ @@ -609,6 +666,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, method: 'ilp' @@ -633,9 +691,11 @@ describe('QuoteService', (): void => { scale: 2 }) sendingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: sendAsset.id }) receivingWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: receiveAsset.id }) }) @@ -679,6 +739,7 @@ describe('QuoteService', (): void => { .mockResolvedValueOnce(mockedQuote) const quote = await quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -722,6 +783,7 @@ describe('QuoteService', (): void => { await expect( quoteService.create({ + tenantId, walletAddressId: sendingWalletAddress.id, receiver: receiver.incomingPayment!.id, debitAmount: { @@ -742,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' @@ -791,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 0255c615f0..5ed02b0144 100644 --- a/packages/backend/src/open_payments/receiver/model.test.ts +++ b/packages/backend/src/open_payments/receiver/model.test.ts @@ -38,9 +38,12 @@ describe('Receiver Model', (): void => { describe('constructor', () => { test('creates receiver', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) const isLocal = true @@ -82,9 +85,12 @@ describe('Receiver Model', (): void => { }) test('throws if incoming payment is completed', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) incomingPayment.state = IncomingPaymentState.Completed @@ -105,9 +111,12 @@ describe('Receiver Model', (): void => { }) test('throws if incoming payment is expired', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + walletAddressId: walletAddress.id, + tenantId: Config.operatorTenantId }) incomingPayment.expiresAt = new Date(Date.now() - 1) @@ -125,9 +134,12 @@ describe('Receiver Model', (): void => { }) test('throws if stream credentials has invalid ILP address', async () => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const incomingPayment = await createIncomingPayment(deps, { - walletAddressId: walletAddress.id + 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 ed3d05ebd7..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 => { @@ -81,6 +83,7 @@ describe('Receiver Service', (): void => { describe('local incoming payment', () => { test('resolves local incoming payment', async () => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, mockServerPort: Config.openPaymentsPort }) const incomingPayment = await createIncomingPayment(deps, { @@ -89,7 +92,8 @@ describe('Receiver Service', (): void => { value: BigInt(5), assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale - } + }, + tenantId: Config.operatorTenantId }) await expect( @@ -289,6 +293,7 @@ describe('Receiver Service', (): void => { }) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, mockServerPort: Config.openPaymentsPort, assetId: asset.id }) @@ -313,7 +318,8 @@ describe('Receiver Service', (): void => { walletAddressUrl: walletAddress.url, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) assert(receiver instanceof Receiver) @@ -347,7 +353,8 @@ describe('Receiver Service', (): void => { walletAddressId: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId: Config.operatorTenantId }) expect(remoteIncomingPaymentCreateSpy).not.toHaveBeenCalled() } @@ -360,7 +367,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.url + walletAddressUrl: walletAddress.url, + tenantId }) ).resolves.toEqual(ReceiverError.InvalidAmount) }) @@ -372,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' @@ -415,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({ @@ -456,7 +465,8 @@ describe('Receiver Service', (): void => { walletAddressUrl: walletAddress.id, incomingAmount, expiresAt, - metadata + metadata, + tenantId }) expect(localIncomingPaymentCreateSpy).not.toHaveBeenCalled() } @@ -471,7 +481,8 @@ describe('Receiver Service', (): void => { await expect( receiverService.create({ - walletAddressUrl: walletAddress.id + walletAddressUrl: walletAddress.id, + tenantId }) ).resolves.toEqual(ReceiverError.UnknownWalletAddress) }) @@ -491,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/errors.ts b/packages/backend/src/open_payments/wallet_address/errors.ts index 03d672762c..6d0f2151cd 100644 --- a/packages/backend/src/open_payments/wallet_address/errors.ts +++ b/packages/backend/src/open_payments/wallet_address/errors.ts @@ -4,7 +4,8 @@ export enum WalletAddressError { InvalidUrl = 'InvalidUrl', UnknownAsset = 'UnknownAsset', UnknownWalletAddress = 'UnknownWalletAddress', - DuplicateWalletAddress = 'DuplicateWalletAddress' + DuplicateWalletAddress = 'DuplicateWalletAddress', + WalletAddressSettingNotFound = 'WalletAddressSettingNotFound' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -17,7 +18,8 @@ export const errorToCode: { [WalletAddressError.InvalidUrl]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownAsset]: GraphQLErrorCode.BadUserInput, [WalletAddressError.UnknownWalletAddress]: GraphQLErrorCode.NotFound, - [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate + [WalletAddressError.DuplicateWalletAddress]: GraphQLErrorCode.Duplicate, + [WalletAddressError.WalletAddressSettingNotFound]: GraphQLErrorCode.NotFound } export const errorToMessage: { @@ -27,5 +29,7 @@ export const errorToMessage: { [WalletAddressError.UnknownAsset]: 'unknown asset', [WalletAddressError.UnknownWalletAddress]: 'unknown wallet address', [WalletAddressError.DuplicateWalletAddress]: - 'Duplicate wallet address found with the same url' + 'Duplicate wallet address found with the same url', + [WalletAddressError.WalletAddressSettingNotFound]: + 'Setting for wallet address has not been found.' } diff --git a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts index c97153ed42..0ea2017292 100644 --- a/packages/backend/src/open_payments/wallet_address/key/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/routes.test.ts @@ -46,7 +46,9 @@ describe('Wallet Address Keys Routes', (): void => { describe('get', (): void => { test('returns 200 with all keys for a wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const keyOption = { walletAddressId: walletAddress.id, @@ -69,7 +71,9 @@ describe('Wallet Address Keys Routes', (): void => { }) test('returns 200 with empty array if no keys for a wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const ctx = createContext({ headers: { Accept: 'application/json' }, @@ -121,7 +125,9 @@ describe('Wallet Address Keys Routes', (): void => { }) test('throws 404 error for inactive wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await walletAddress.$query().patch({ deactivatedAt: new Date() }) diff --git a/packages/backend/src/open_payments/wallet_address/key/service.test.ts b/packages/backend/src/open_payments/wallet_address/key/service.test.ts index 8fa5802486..57363cb649 100644 --- a/packages/backend/src/open_payments/wallet_address/key/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/key/service.test.ts @@ -31,7 +31,9 @@ describe('Wallet Address Key Service', (): void => { }) beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { diff --git a/packages/backend/src/open_payments/wallet_address/middleware.test.ts b/packages/backend/src/open_payments/wallet_address/middleware.test.ts index 7aeb1f3ea5..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( { @@ -355,7 +462,9 @@ describe('Wallet Address Middleware', (): void => { }) test('throws error for deactivated wallet address', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) ctx.walletAddressUrl = walletAddress.url await walletAddress.$query().patch({ deactivatedAt: new Date() }) @@ -372,7 +481,9 @@ describe('Wallet Address Middleware', (): void => { }) test('sets walletAddress on context and calls next', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) ctx.walletAddressUrl = walletAddress.url await expect( diff --git a/packages/backend/src/open_payments/wallet_address/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.test.ts b/packages/backend/src/open_payments/wallet_address/model.test.ts index 6aaeb79184..3dd586873d 100644 --- a/packages/backend/src/open_payments/wallet_address/model.test.ts +++ b/packages/backend/src/open_payments/wallet_address/model.test.ts @@ -413,7 +413,9 @@ describe('Models', (): void => { test.each(deactivatedAtCases)( '$description', async ({ value, expectedIsActive }) => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) if (value) { await walletAddress .$query(appContainer.knex) diff --git a/packages/backend/src/open_payments/wallet_address/model.ts b/packages/backend/src/open_payments/wallet_address/model.ts index 81dd603a1d..972c1ce2f5 100644 --- a/packages/backend/src/open_payments/wallet_address/model.ts +++ b/packages/backend/src/open_payments/wallet_address/model.ts @@ -8,6 +8,7 @@ import { WebhookEvent } from '../../webhook/model' import { WalletAddressKey } from '../../open_payments/wallet_address/key/model' import { AmountJSON } from '../amount' import { WalletAddressAdditionalProperty } from './additional_property/model' +import { Tenant } from '../../tenants/model' export class WalletAddress extends BaseModel @@ -18,6 +19,14 @@ export class WalletAddress } static relationMappings = () => ({ + tenant: { + relation: Model.HasOneRelation, + modelClass: Tenant, + join: { + from: 'walletAddresses.tenantId', + to: 'tenants.id' + } + }, asset: { relation: Model.HasOneRelation, modelClass: Asset, @@ -47,12 +56,14 @@ export class WalletAddress public keys?: WalletAddressKey[] public additionalProperties?: WalletAddressAdditionalProperty[] - public url!: string + public address!: string public publicName?: string public readonly assetId!: string public asset!: Asset + public readonly tenantId!: string + // The cumulative received amount tracked by // `wallet_address.web_monetization` webhook events. // The value should be equivalent to the following query: @@ -113,7 +124,7 @@ export class WalletAddress resourceServer: string }): OpenPaymentsWalletAddress { const returnVal: OpenPaymentsWalletAddress = { - id: this.url, + id: this.address, publicName: this.publicName, assetCode: this.asset.code, assetScale: this.asset.scale, @@ -180,6 +191,7 @@ export interface GetOptions { id: string client?: string walletAddressId?: string + tenantId?: string } export interface ListOptions { @@ -187,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 ee4777f43b..e30d4056fb 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.test.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.test.ts @@ -62,6 +62,7 @@ describe('Wallet Address Routes', (): void => { test('throws 404 error for inactive wallet address', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: faker.person.firstName() }) @@ -102,6 +103,7 @@ describe('Wallet Address Routes', (): void => { addPropNotVisibleInOpenPayments.fieldValue = 'it-is-not' addPropNotVisibleInOpenPayments.visibleInOpenPayments = false const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, publicName: faker.person.firstName(), additionalProperties: [addProp, addPropNotVisibleInOpenPayments] }) @@ -118,8 +120,9 @@ describe('Wallet Address Routes', (): void => { publicName: walletAddress.publicName, assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - authServer: config.authServerGrantUrl, - resourceServer: config.openPaymentsUrl, + // Ensure the tenant id is returned for auth and resource server: + authServer: `${config.authServerGrantUrl}/${config.operatorTenantId}`, + resourceServer: `${config.openPaymentsUrl}/${config.operatorTenantId}`, additionalProperties: { [addProp.fieldKey]: addProp.fieldValue } @@ -145,6 +148,7 @@ describe('Wallet Address Routes', (): void => { test('returns wallet address', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: config.operatorTenantId, publicName: faker.person.firstName() }) @@ -160,8 +164,9 @@ describe('Wallet Address Routes', (): void => { publicName: walletAddress.publicName, assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, - authServer: config.authServerGrantUrl, - resourceServer: config.openPaymentsUrl + // Ensure the tenant id is returned for auth and resource server: + authServer: `${config.authServerGrantUrl}/${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 652394985e..f3b75c989d 100644 --- a/packages/backend/src/open_payments/wallet_address/routes.ts +++ b/packages/backend/src/open_payments/wallet_address/routes.ts @@ -11,6 +11,7 @@ import { } from '../../shared/pagination' import { OpenPaymentsServerRouteError } from '../route-errors' import { IAppConfig } from '../../config/app' +import { ensureTrailingSlash } from '../../shared/utils' interface ServiceDependencies { config: IAppConfig @@ -60,8 +61,8 @@ export async function getWalletAddress( ) ctx.body = walletAddress.toOpenPaymentsType({ - authServer: deps.config.authServerGrantUrl, - resourceServer: deps.config.openPaymentsUrl + authServer: `${ensureTrailingSlash(deps.config.authServerGrantUrl)}${walletAddress.tenantId}`, + resourceServer: `${ensureTrailingSlash(deps.config.openPaymentsUrl)}${walletAddress.tenantId}` }) } @@ -94,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 b2b7245010..403f7a2f03 100644 --- a/packages/backend/src/open_payments/wallet_address/service.test.ts +++ b/packages/backend/src/open_payments/wallet_address/service.test.ts @@ -12,6 +12,7 @@ import { CreateOptions, FORBIDDEN_PATHS, WalletAddressService } from './service' import { AccountingService } from '../../accounting/service' import { createTestApp, TestContainer } from '../../tests/app' import { createAsset } from '../../tests/asset' +import { createTenant } from '../../tests/tenant' import { createWalletAddress } from '../../tests/walletAddress' import { truncateTables } from '../../tests/tableManager' import { Config, IAppConfig } from '../../config/app' @@ -26,6 +27,8 @@ import { sleep } from '../../shared/utils' import { withConfigOverride } from '../../tests/helpers' import { WalletAddressAdditionalProperty } from './additional_property/model' import { CacheDataStore } from '../../middleware/cache/data-stores' +import { createTenantSettings } from '../../tests/tenantSettings' +import { TenantSettingKeys } from '../../tenants/settings/model' describe('Open Payments Wallet Address Service', (): void => { let deps: IocContract @@ -56,14 +59,28 @@ describe('Open Payments Wallet Address Service', (): void => { await appContainer.shutdown() }) - describe('Create or Get Wallet Address', (): void => { + describe('Create or Get Wallet Address3', (): void => { + let tenantId: string let options: CreateOptions beforeEach(async (): Promise => { - const { id: assetId } = await createAsset(deps) + tenantId = (await createTenant(deps)).id + const { id: assetId } = await createAsset(deps, undefined, tenantId) + + await createTenantSettings(deps, { + tenantId: tenantId, + setting: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: 'https://alice.me' + } + ] + }) + options = { - url: 'https://alice.me/.well-known/pay', - assetId + address: 'https://alice.me/.well-known/pay', + assetId, + tenantId } }) @@ -86,6 +103,33 @@ describe('Open Payments Wallet Address Service', (): void => { } ) + test.each` + setting | address | generated + ${'https://alice.me/ilp'} | ${'https://alice.me/ilp/test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp'} | ${'test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp'} | ${'/test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp/'} | ${'test'} | ${'https://alice.me/ilp/test'} + ${'https://alice.me/ilp/'} | ${'/test'} | ${'https://alice.me/ilp/test'} + `( + 'should create address $generated with address $address and setting $setting', + async ({ setting, address, generated }): Promise => { + await createTenantSettings(deps, { + tenantId: tenantId, + setting: [ + { key: TenantSettingKeys.WALLET_ADDRESS_URL.name, value: setting } + ] + }) + + const walletAddress = await walletAddressService.create({ + ...options, + address + }) + + assert.ok(!isWalletAddressError(walletAddress)) + expect(walletAddress.address).toEqual(generated) + } + ) + test('Cannot create wallet address with unknown asset', async (): Promise => { await expect( walletAddressService.create({ @@ -107,7 +151,7 @@ describe('Open Payments Wallet Address Service', (): void => { await expect( walletAddressService.create({ ...options, - url + address: url }) ).resolves.toEqual(WalletAddressError.InvalidUrl) } @@ -116,17 +160,17 @@ describe('Open Payments Wallet Address Service', (): void => { test.each(FORBIDDEN_PATHS.map((path) => [path]))( 'Wallet address cannot be created with forbidden url path (%s)', async (path): Promise => { - const url = `https://alice.me${path}` + const address = `https://alice.me${path}` await expect( walletAddressService.create({ ...options, - url + address }) ).resolves.toEqual(WalletAddressError.InvalidUrl) await expect( walletAddressService.create({ ...options, - url: `${url}/more/path` + address: `${address}/more/path` }) ).resolves.toEqual(WalletAddressError.InvalidUrl) } @@ -141,26 +185,26 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('Creating wallet address with case insensitiveness', async (): Promise => { - const url = 'https://Alice.me/pay' + const address = 'https://Alice.me/pay' await expect( walletAddressService.create({ ...options, - url + address }) - ).resolves.toMatchObject({ url: url.toLowerCase() }) + ).resolves.toMatchObject({ address: address.toLowerCase() }) }) test('Wallet address cannot be created if the url is duplicated', async (): Promise => { - const url = 'https://Alice.me/pay' + const address = 'https://Alice.me/pay' const wallet = walletAddressService.create({ ...options, - url + address }) assert.ok(!isWalletAddressError(wallet)) await expect( walletAddressService.create({ ...options, - url + address }) ).resolves.toEqual(WalletAddressError.DuplicateWalletAddress) }) @@ -176,7 +220,9 @@ describe('Open Payments Wallet Address Service', (): void => { `( 'Wallet address with initial isActive of $initialIsActive can be updated with $status status ', async ({ initialIsActive, status, expectedIsActive }): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) if (!initialIsActive) { await walletAddress.$query(knex).patch({ deactivatedAt: new Date() }) @@ -198,6 +244,7 @@ describe('Open Payments Wallet Address Service', (): void => { test('publicName', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: 'Initial Name' }) const newName = 'New Name' @@ -223,7 +270,9 @@ describe('Open Payments Wallet Address Service', (): void => { incomingPaymentExpiryMaxMs: 2592000000 * 3 }, async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const now = new Date('2023-06-01T00:00:00Z').getTime() jest.useFakeTimers({ now }) @@ -242,7 +291,8 @@ describe('Open Payments Wallet Address Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId: Config.operatorTenantId }) await walletAddressService.update({ @@ -266,7 +316,9 @@ describe('Open Payments Wallet Address Service', (): void => { () => config, { walletAddressDeactivationPaymentGracePeriodMs: 2592000000 }, async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) const now = new Date('2023-06-01T00:00:00Z').getTime() jest.useFakeTimers({ now }) @@ -284,7 +336,8 @@ describe('Open Payments Wallet Address Service', (): void => { metadata: { description: 'Test incoming payment', externalRef: '#123' - } + }, + tenantId: Config.operatorTenantId }) await walletAddressService.update({ @@ -302,6 +355,7 @@ describe('Open Payments Wallet Address Service', (): void => { describe('additionalProperties', (): void => { test('should do nothing if additionalProperties is undefined', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, publicName: 'Initial Name', additionalProperties: [ { @@ -331,6 +385,7 @@ describe('Open Payments Wallet Address Service', (): void => { test('should update to [] (deleting all) when additionalProperties is []', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'key1', @@ -363,6 +418,7 @@ describe('Open Payments Wallet Address Service', (): void => { }) test('should replace existing additionalProperties', async (): Promise => { const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, additionalProperties: [ { fieldKey: 'key1', @@ -424,17 +480,19 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Get Wallet Address By Url', (): void => { test('can retrieve wallet address by url', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await expect( - walletAddressService.getByUrl(walletAddress.url) + walletAddressService.getByUrl(walletAddress.address) ).resolves.toEqual(walletAddress) await expect( - walletAddressService.getByUrl(walletAddress.url + '/path') + walletAddressService.getByUrl(walletAddress.address + '/path') ).resolves.toBeUndefined() await expect( - walletAddressService.getByUrl('prefix+' + walletAddress.url) + walletAddressService.getByUrl('prefix+' + walletAddress.address) ).resolves.toBeUndefined() }) @@ -455,9 +513,11 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Get Or Poll Wallet Addres By Url', (): void => { describe('existing wallet address', (): void => { test('can retrieve wallet address by url', async (): Promise => { - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) await expect( - walletAddressService.getOrPollByUrl(walletAddress.url) + walletAddressService.getOrPollByUrl(walletAddress.address) ).resolves.toEqual(walletAddress) }) }) @@ -501,7 +561,8 @@ describe('Open Payments Wallet Address Service', (): void => { (async () => { await sleep(5) return createWalletAddress(deps, { - url: walletAddressUrl + tenantId: Config.operatorTenantId, + address: walletAddressUrl }) })() ]) @@ -530,7 +591,8 @@ describe('Open Payments Wallet Address Service', (): void => { describe('Wallet Address pagination', (): void => { describe('getPage', (): void => { getPageTests({ - createModel: () => createWalletAddress(deps), + createModel: () => + createWalletAddress(deps, { tenantId: Config.operatorTenantId }), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => walletAddressService.getPage(pagination, sortOrder) }) @@ -541,7 +603,9 @@ describe('Open Payments Wallet Address Service', (): void => { let walletAddress: WalletAddress beforeEach(async (): Promise => { - walletAddress = await createWalletAddress(deps) + walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) }) describe.each` @@ -661,6 +725,7 @@ describe('Open Payments Wallet Address Service', (): void => { beforeEach(async (): Promise => { walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, createLiquidityAccount: true }) }) @@ -734,6 +799,7 @@ describe('Open Payments Wallet Address Service', (): void => { for (let i = 0; i < 5; i++) { walletAddresses.push( await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId, createLiquidityAccount: true }) @@ -845,7 +911,9 @@ describe('Open Payments Wallet Address Service using Cache', (): void => { expectedCallCount }): Promise => { const spyCacheSet = jest.spyOn(walletAddressCache, 'set') - const walletAddress = await createWalletAddress(deps) + const walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) expect(spyCacheSet).toHaveBeenCalledTimes(1) if (!initialIsActive) { @@ -878,7 +946,7 @@ describe('Open Payments Wallet Address Service using Cache', (): void => { walletAddress.id, expect.objectContaining({ id: walletAddress.id, - url: walletAddress.url + address: walletAddress.address }) ) diff --git a/packages/backend/src/open_payments/wallet_address/service.ts b/packages/backend/src/open_payments/wallet_address/service.ts index b2b79bd5c3..77a3038d1d 100644 --- a/packages/backend/src/open_payments/wallet_address/service.ts +++ b/packages/backend/src/open_payments/wallet_address/service.ts @@ -28,6 +28,8 @@ import { poll } from '../../shared/utils' import { WalletAddressAdditionalProperty } from './additional_property/model' import { AssetService } from '../../asset/service' import { CacheDataStore } from '../../middleware/cache/data-stores' +import { TenantSetting, TenantSettingKeys } from '../../tenants/settings/model' +import { TenantSettingService } from '../../tenants/settings/service' interface Options { publicName?: string @@ -39,7 +41,8 @@ export type WalletAddressAdditionalPropertyInput = Pick< > export interface CreateOptions extends Options { - url: string + tenantId: string + address: string assetId: string additionalProperties?: WalletAddressAdditionalPropertyInput[] } @@ -64,12 +67,13 @@ export interface WalletAddressService { id: string, includeVisibleOnlyAddProps: boolean ): Promise - get(id: string): Promise - getByUrl(url: string): Promise + get(id: string, tenantId?: string): Promise + getByUrl(url: string, tenantId?: string): Promise getOrPollByUrl(url: string): Promise getPage( pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise processNext(): Promise triggerEvents(limit: number): Promise @@ -82,6 +86,7 @@ interface ServiceDependencies extends BaseService { webhookService: WebhookService assetService: AssetService walletAddressCache: CacheDataStore + tenantSettingService: TenantSettingService } export async function createWalletAddressService({ @@ -91,7 +96,8 @@ export async function createWalletAddressService({ accountingService, webhookService, assetService, - walletAddressCache + walletAddressCache, + tenantSettingService }: ServiceDependencies): Promise { const log = logger.child({ service: 'WalletAddressService' @@ -103,7 +109,8 @@ export async function createWalletAddressService({ accountingService, webhookService, assetService, - walletAddressCache + walletAddressCache, + tenantSettingService } return { create: (options) => createWalletAddress(deps, options), @@ -114,11 +121,11 @@ export async function createWalletAddressService({ walletAddressId, includeVisibleOnlyAddProps ), - get: (id) => getWalletAddress(deps, id), - getByUrl: (url) => getWalletAddressByUrl(deps, url), + get: (id, tenantId) => getWalletAddress(deps, id, tenantId), + getByUrl: (url, tenantId) => getWalletAddressByUrl(deps, url, tenantId), getOrPollByUrl: (url) => getOrPollByUrl(deps, url), - getPage: (pagination?, sortOrder?) => - getWalletAddressPage(deps, pagination, sortOrder), + getPage: (pagination?, sortOrder?, tenantId?) => + getWalletAddressPage(deps, pagination, sortOrder, tenantId), processNext: () => processNextWalletAddress(deps), triggerEvents: (limit) => triggerWalletAddressEvents(deps, limit) } @@ -163,11 +170,61 @@ async function createWalletAddress( deps: ServiceDependencies, options: CreateOptions ): Promise { - if (!isValidWalletAddressUrl(options.url)) { + const found = (await deps.tenantSettingService.get({ + tenantId: options.tenantId, + key: TenantSettingKeys.WALLET_ADDRESS_URL.name + })) as TenantSetting[] + + if (!found || found.length === 0) { + return WalletAddressError.WalletAddressSettingNotFound + } + + const tenantWalletAddressUrl = new URL(found[0].value) + + let tenantBaseUrl = tenantWalletAddressUrl.toString() + if (!tenantWalletAddressUrl.pathname.endsWith('/')) { + tenantBaseUrl = + tenantWalletAddressUrl.origin + tenantWalletAddressUrl.pathname + '/' + } + + const isValidUrl = (str: string): boolean => { + try { + new URL(str) + return true + } catch { + return false + } + } + + let finalWalletAddressUrl: string + if (isValidUrl(options.address)) { + // in case that client provided full url, verify that it starts with the tenant's URL + const walletAddressUrl = new URL(options.address) + if (!walletAddressUrl.href.startsWith(tenantWalletAddressUrl.href)) { + return WalletAddressError.InvalidUrl + } + finalWalletAddressUrl = walletAddressUrl.toString() + } else { + // in case that client provided just the path / wallet address name, construct the address using the wallet address url from tenant setting + try { + let relativePath = options.address + if (relativePath.startsWith('/')) { + relativePath = relativePath.substring(1) + } + finalWalletAddressUrl = tenantBaseUrl + relativePath + } catch (err) { + return WalletAddressError.InvalidUrl + } + } + + if (!isValidWalletAddressUrl(finalWalletAddressUrl)) { return WalletAddressError.InvalidUrl } try { + const asset = await deps.assetService.get(options.assetId, options.tenantId) + if (!asset) return WalletAddressError.UnknownAsset + // Remove blank key/value pairs: const additionalProperties = options.additionalProperties ? cleanAdditionalProperties(options.additionalProperties) @@ -176,13 +233,13 @@ async function createWalletAddress( const walletAddress = await WalletAddress.query( deps.knex ).insertGraphAndFetch({ - url: options.url.toLowerCase(), + tenantId: options.tenantId, + address: finalWalletAddressUrl.toLowerCase(), publicName: options.publicName, - assetId: options.assetId, + assetId: asset.id, additionalProperties: additionalProperties }) - const asset = await deps.assetService.get(walletAddress.assetId) - if (asset) walletAddress.asset = asset + walletAddress.asset = asset await deps.walletAddressCache.set(walletAddress.id, walletAddress) return walletAddress @@ -260,12 +317,18 @@ async function updateWalletAddress( async function getWalletAddress( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { - const walletAdd = await deps.walletAddressCache.get(id) - if (walletAdd) return walletAdd + const inMem = await deps.walletAddressCache.get(id) + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } + + const query = WalletAddress.query(deps.knex) + if (tenantId) query.andWhere({ tenantId }) - const walletAddress = await WalletAddress.query(deps.knex).findById(id) + const walletAddress = await query.findById(id) if (walletAddress) { const asset = await deps.assetService.get(walletAddress.assetId) if (asset) walletAddress.asset = asset @@ -323,10 +386,14 @@ async function getOrPollByUrl( async function getWalletAddressByUrl( deps: ServiceDependencies, - url: string + url: string, + tenantId?: string ): Promise { - const walletAddress = await WalletAddress.query(deps.knex).findOne({ - url: url.toLowerCase() + const query = WalletAddress.query(deps.knex) + if (tenantId) query.andWhere({ tenantId }) + + const walletAddress = await query.findOne({ + address: url.toLowerCase() }) if (walletAddress) { const asset = await deps.assetService.get(walletAddress.assetId) @@ -338,11 +405,18 @@ async function getWalletAddressByUrl( async function getWalletAddressPage( deps: ServiceDependencies, pagination?: Pagination, - sortOrder?: SortOrder + sortOrder?: SortOrder, + tenantId?: string ): Promise { - return await WalletAddress.query(deps.knex) - .getPage(pagination, sortOrder) - .withGraphFetched('asset') + const query = WalletAddress.query(deps.knex) + if (tenantId) query.where({ tenantId }) + + const addresses = await query.getPage(pagination, sortOrder) + for (const address of addresses) { + const asset = await deps.assetService.get(address.assetId) + if (asset) address.asset = asset + } + return addresses } // Returns the id of the processed wallet address (if any). diff --git a/packages/backend/src/payment-method/handler/service.test.ts b/packages/backend/src/payment-method/handler/service.test.ts index 254963967c..5bcfaf36ea 100644 --- a/packages/backend/src/payment-method/handler/service.test.ts +++ b/packages/backend/src/payment-method/handler/service.test.ts @@ -45,8 +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, assetId: asset.id }) @@ -73,8 +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, assetId: asset.id }) @@ -103,8 +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, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -113,6 +119,7 @@ describe('PaymentMethodHandlerService', (): void => { receivingWalletAddress: walletAddress, method: 'ilp', quoteOptions: { + tenantId, debitAmount: { assetCode: walletAddress.asset.code, assetScale: walletAddress.asset.scale, @@ -137,8 +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, assetId: asset.id }) const { receiver, outgoingPayment } = @@ -147,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 b2f37b7025..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,10 +69,12 @@ describe('IlpPaymentService', (): void => { }) walletAddressMap['USD'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['USD'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['EUR'].id }) }) @@ -338,7 +342,8 @@ describe('IlpPaymentService', (): void => { quoteId: uuid(), walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) } @@ -433,7 +438,8 @@ describe('IlpPaymentService', (): void => { assetCode: 'USD', assetScale: 2, value: 100n - } + }, + tenantId: Config.operatorTenantId }) } @@ -665,6 +671,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -695,6 +702,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, exchangeRate: 1, debitAmount: { value: 100n, @@ -745,6 +753,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -785,6 +794,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -825,6 +835,7 @@ describe('IlpPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -862,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/spsp/middleware.test.ts b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts index 9a4705f9c6..896c30dac6 100644 --- a/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts +++ b/packages/backend/src/payment-method/ilp/spsp/middleware.test.ts @@ -37,6 +37,7 @@ describe('SPSP Middleware', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) ctx = setup({ diff --git a/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts b/packages/backend/src/payment-method/ilp/stream-credentials/service.test.ts index 22c4f4842b..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 @@ -27,9 +27,12 @@ describe('Stream Credentials Service', (): void => { }) beforeEach(async (): Promise => { - const { id: walletAddressId } = await createWalletAddress(deps) + const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId + }) incomingPayment = await createIncomingPayment(deps, { - walletAddressId + 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 72196b298d..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,14 +71,17 @@ describe('LocalPaymentService', (): void => { }) walletAddressMap['USD'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['USD'].id }) walletAddressMap['USD_9'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['USD_9'].id }) walletAddressMap['EUR'] = await createWalletAddress(deps, { + tenantId, assetId: assetMap['EUR'].id }) }) @@ -247,7 +252,8 @@ describe('LocalPaymentService', (): void => { const options: StartQuoteOptions = { walletAddress: walletAddressMap['USD'], receiver: await createReceiver(deps, walletAddressMap['USD'], { - incomingAmount + incomingAmount, + tenantId: Config.operatorTenantId }) } @@ -406,6 +412,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -436,6 +443,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -473,6 +481,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -512,6 +521,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -551,6 +561,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -590,6 +601,7 @@ describe('LocalPaymentService', (): void => { receivingWalletAddress: walletAddressMap['USD'], method: 'ilp', quoteOptions: { + tenantId, debitAmount: { value: 100n, assetScale: walletAddressMap['USD'].asset.scale, @@ -630,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/baseModel.ts b/packages/backend/src/shared/baseModel.ts index 94e9c383b7..8a2c33a796 100644 --- a/packages/backend/src/shared/baseModel.ts +++ b/packages/backend/src/shared/baseModel.ts @@ -49,6 +49,7 @@ class PaginationQueryBuilder extends QueryBuilder< * Please read the spec before changing things: * https://relay.dev/graphql/connections.htm * @param pagination Pagination - cursors and limits. + * @param sortOrder SortOrder - Asc/Desc sort order. * @returns Model[] An array of Models that form a page. */ getPage( diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index 18be4c5541..85d3df3e01 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -47,6 +47,7 @@ describe('Pagination', (): void => { beforeEach(async (): Promise => { const asset = await createAsset(deps) walletAddress = await createWalletAddress(deps, { + tenantId: Config.operatorTenantId, assetId: asset.id }) }) @@ -73,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 @@ -82,11 +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, assetId: asset.id }) secondaryWalletAddress = await createWalletAddress(deps, { + tenantId, assetId: asset.id }) debitAmount = { @@ -118,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) } @@ -171,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', @@ -228,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, @@ -300,9 +308,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = assetIds[cursor] else pagination.after = assetIds[cursor] } - const page = await assetService.getPage(pagination) + const page = await assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }) const pageInfo = await getPageInfo({ - getPage: (pagination) => assetService.getPage(pagination), + getPage: (pagination) => + assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }), page }) expect(pageInfo).toEqual({ diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index 1a991d7915..63b00099ca 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -1,13 +1,27 @@ +import crypto from 'crypto' import { IocContract } from '@adonisjs/fold' import { Redis } from 'ioredis' -import { isValidHttpUrl, poll, requestWithTimeout, sleep } from './utils' +import { faker } from '@faker-js/faker' +import { v4 } from 'uuid' +import assert from 'assert' +import { + isValidHttpUrl, + poll, + requestWithTimeout, + sleep, + getTenantFromApiSignature, + ensureTrailingSlash, + urlWithoutTenantId +} from './utils' import { AppServices, AppContext } from '../app' import { TestContainer, createTestApp } from '../tests/app' import { initIocContainer } from '..' import { verifyApiSignature } from './utils' import { generateApiSignature } from '../tests/apiSignature' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { createContext } from '../tests/context' +import { Tenant } from '../tenants/model' +import { truncateTables } from '../tests/tableManager' describe('utils', (): void => { describe('isValidHttpUrl', (): void => { @@ -262,4 +276,194 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('tenant/operator admin api signatures', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenant: Tenant + let operator: Tenant + let config: IAppConfig + let redis: Redis + + const operatorApiSecret = crypto.randomBytes(8).toString('base64') + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + adminApiSecret: operatorApiSecret + }) + appContainer = await createTestApp(deps) + config = await deps.use('config') + redis = await deps.use('redis') + }) + + beforeEach(async (): Promise => { + tenant = await Tenant.query(appContainer.knex).insertAndFetch({ + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: crypto.randomBytes(8).toString('base64'), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + }) + + operator = await Tenant.query(appContainer.knex).insertAndFetch({ + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: operatorApiSecret, + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + }) + }) + + afterEach(async (): Promise => { + await redis.flushall() + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + test.each` + isOperator | description + ${false} | ${'tenanted non-operator'} + ${true} | ${'tenanted operator'} + `( + 'returns if $description request has valid signature', + async ({ isOperator }): Promise => { + const requestBody = { test: 'value' } + + const signature = isOperator + ? generateApiSignature( + operator.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + : generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': isOperator ? operator.id : tenant.id + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + assert.ok(result) + expect(result.tenant).toEqual(isOperator ? operator : tenant) + + if (isOperator) { + expect(result.isOperator).toEqual(true) + } else { + expect(result.isOperator).toEqual(false) + } + } + ) + + test("returns undefined when signature isn't signed with tenant secret", async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + 'wrongsecret', + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': tenant.id + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined + }) + + test('returns undefined if tenant id is not included', async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + + ctx.request.body = requestBody + + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() + }) + + test('returns undefined if tenant does not exist', async (): Promise => { + const requestBody = { test: 'value' } + const signature = generateApiSignature( + tenant.apiSecret, + Config.adminApiSignatureVersion, + requestBody + ) + const ctx = createContext( + { + headers: { + Accept: 'application/json', + signature, + 'tenant-id': v4() + }, + url: '/graphql' + }, + {}, + appContainer.container + ) + + ctx.request.body = requestBody + + const tenantService = await deps.use('tenantService') + const getSpy = jest.spyOn(tenantService, 'get') + const result = await getTenantFromApiSignature(ctx, config) + expect(result).toBeUndefined() + expect(getSpy).toHaveBeenCalled() + }) + }) + + test('test ensuring trailing slash', async (): Promise => { + const path = '/utils' + + expect(ensureTrailingSlash(path)).toBe(`${path}/`) + expect(ensureTrailingSlash(`${path}/`)).toBe(`${path}/`) + }) + + test('test tenant id stripped from url', async (): Promise => { + expect( + urlWithoutTenantId( + 'http://happy-life-bank-test-auth:4106/cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d' + ) + ).toBe('http://happy-life-bank-test-auth:4106') + expect(urlWithoutTenantId('http://happy-life')).toBe('http://happy-life') + }) }) diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index ab40ea3cc9..9046f7ba06 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -4,6 +4,7 @@ import { createHmac } from 'crypto' import { canonicalize } from 'json-canonicalize' import { IAppConfig } from '../config/app' import { AppContext } from '../app' +import { Tenant } from '../tenants/model' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -97,7 +98,7 @@ export async function poll(args: PollArgs): Promise { } /** - * Omit distrubuted to all types in a union. + * Omit distributed to all types in a union. * @example * type WithoutA = UnionOmit<{ a: number; c: number } | { b: number }, 'a'> // { c: number } | { b: number } * const withoutAOK: WithoutA = { c: 1 } // OK @@ -113,20 +114,17 @@ function getSignatureParts(signature: string) { const signatureParts = signature.split(', ') const timestamp = signatureParts[0].split('=')[1] const signatureVersionAndDigest = signatureParts[1].split('=') - const signatureVersion = signatureVersionAndDigest[0].replace('v', '') - const signatureDigest = signatureVersionAndDigest[1] + const version = signatureVersionAndDigest[0].replace('v', '') + const digest = signatureVersionAndDigest[1] - return { - timestamp, - version: signatureVersion, - digest: signatureDigest - } + return { timestamp, version, digest } } function verifyApiSignatureDigest( signature: string, request: AppContext['request'], - config: IAppConfig + adminApiSignatureVersion: number, + secret: string ): boolean { const { body } = request const { @@ -135,12 +133,12 @@ function verifyApiSignatureDigest( timestamp } = getSignatureParts(signature as string) - if (Number(signatureVersion) !== config.adminApiSignatureVersion) { + if (Number(signatureVersion) !== adminApiSignatureVersion) { return false } const payload = `${timestamp}.${canonicalize(body)}` - const hmac = createHmac('sha256', config.adminApiSecret as string) + const hmac = createHmac('sha256', secret) hmac.update(payload) const digest = hmac.digest('hex') @@ -171,6 +169,53 @@ async function canApiSignatureBeProcessed( return true } +export interface TenantApiSignatureResult { + tenant: Tenant + isOperator: boolean +} + +/* + Verifies http signatures by first attempting to replicate it with a secret + associated with a tenant id in the headers. + + If a tenant secret can replicate the signature, the request is tenanted to that particular tenant. + If the environment admin secret matches the tenant's secret, then it is an operator request with elevated permissions. + If neither can replicate the signature then it is unauthorized. +*/ +export async function getTenantFromApiSignature( + ctx: AppContext, + config: IAppConfig +): Promise { + const { headers } = ctx.request + const signature = headers['signature'] + if (!signature) { + return undefined + } + + const tenantService = await ctx.container.use('tenantService') + const tenantId = headers['tenant-id'] as string + const tenant = tenantId ? await tenantService.get(tenantId) : undefined + + if (!tenant) return undefined + + if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) + return undefined + + if ( + tenant.apiSecret && + verifyApiSignatureDigest( + signature as string, + ctx.request, + config.adminApiSignatureVersion, + tenant.apiSecret + ) + ) { + return { tenant, isOperator: tenant.apiSecret === config.adminApiSecret } + } + + return undefined +} + export async function verifyApiSignature( ctx: AppContext, config: IAppConfig @@ -184,5 +229,23 @@ export async function verifyApiSignature( if (!(await canApiSignatureBeProcessed(signature as string, ctx, config))) return false - return verifyApiSignatureDigest(signature as string, ctx.request, config) + return verifyApiSignatureDigest( + signature as string, + ctx.request, + config.adminApiSignatureVersion, + config.adminApiSecret as string + ) +} + +export function ensureTrailingSlash(str: string): string { + if (!str.endsWith('/')) return `${str}/` + return str +} + +/** + * @param url remove the tenant id from the {url} + */ +export function urlWithoutTenantId(url: string): string { + if (url.length > 36 && validateId(url.slice(-36))) return url.slice(0, -37) + return url } diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts new file mode 100644 index 0000000000..81503dfcb1 --- /dev/null +++ b/packages/backend/src/tenants/model.ts @@ -0,0 +1,39 @@ +import { BaseModel } from '../shared/baseModel' +import { Model, Pojo } from 'objection' +import { TenantSetting } from './settings/model' + +export class Tenant extends BaseModel { + public static get tableName(): string { + return 'tenants' + } + + public static get relationMappings() { + return { + settings: { + relation: Model.HasManyRelation, + modelClass: TenantSetting, + join: { + from: 'tenants.id', + to: 'tenantSettings.tenantId' + } + } + } + } + + public email!: string + public apiSecret!: string + public idpConsentUrl!: string + public idpSecret!: string + public publicName?: string + public settings?: TenantSetting[] + + public deletedAt?: Date + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + deletedAt: json.deletedAt.toISOString() + } + } +} diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts new file mode 100644 index 0000000000..39b02eadef --- /dev/null +++ b/packages/backend/src/tenants/service.test.ts @@ -0,0 +1,478 @@ +import assert from 'assert' +import { faker } from '@faker-js/faker' +import { IocContract } from '@adonisjs/fold' +import nock from 'nock' +import { Knex } from 'knex' +import { AppServices } from '../app' +import { initIocContainer } from '..' +import { createTestApp, TestContainer } from '../tests/app' +import { TenantService } from './service' +import { Config, IAppConfig } from '../config/app' +import { truncateTables } from '../tests/tableManager' +import { Tenant } from './model' +import { getPageTests } from '../shared/baseModel.test' +import { Pagination, SortOrder } from '../shared/baseModel' +import { createTenant } from '../tests/tenant' +import { CacheDataStore } from '../middleware/cache/data-stores' +import { AuthServiceClient } from '../auth-service-client/client' +import { withConfigOverride } from '../tests/helpers' +import { TenantSetting, TenantSettingKeys } from './settings/model' +import { TenantSettingService } from './settings/service' + +describe('Tenant Service', (): void => { + let deps: IocContract + let config: IAppConfig + let appContainer: TestContainer + let tenantService: TenantService + let knex: Knex + const dbSchema = 'tenant_service_test_schema' + let authServiceClient: AuthServiceClient + let tenantSettingsService: TenantSettingService + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + dbSchema + }) + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + knex = await deps.use('knex') + config = await deps.use('config') + authServiceClient = await deps.use('authServiceClient') + tenantSettingsService = await deps.use('tenantSettingService') + }) + + afterEach(async (): Promise => { + await truncateTables(knex, true, dbSchema) + }) + + afterAll(async (): Promise => { + nock.cleanAll() + await appContainer.shutdown() + }) + + describe('Tenant pagination', (): void => { + describe('getPage', (): void => { + getPageTests({ + createModel: () => createTenant(deps), + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => + tenantService.getPage(pagination, sortOrder) + }) + }) + }) + + describe('get', (): void => { + test('can get a tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const createdTenant = + await Tenant.query(knex).insertAndFetch(createOptions) + + const tenant = await tenantService.get(createdTenant.id) + assert.ok(tenant) + expect(tenant).toEqual(createdTenant) + }) + + test('returns deletedAt set 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() + }) + + 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() + }) + }) + + describe('create', (): void => { + test('can create a tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(createOptions) + + expect(tenant).toEqual(expect.objectContaining(createOptions)) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + id: tenant.id, + idpSecret: createOptions.idpSecret, + idpConsentUrl: createOptions.idpConsentUrl + }) + ) + + const tenantSettings = await TenantSetting.query().where( + 'tenantId', + tenant.id + ) + expect(tenantSettings.length).toBeGreaterThan(0) + }) + + test('can create a tenant with a setting', async () => { + const walletAddressUrl = 'https://example.com' + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + settings: [ + { + key: TenantSettingKeys.WALLET_ADDRESS_URL.name, + value: walletAddressUrl + } + ] + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(createOptions) + const tenantSetting = await TenantSetting.query() + .where('tenantId', tenant.id) + .andWhere('key', TenantSettingKeys.WALLET_ADDRESS_URL.name) + + expect(tenantSetting.length).toBe(1) + expect(tenantSetting[0].value).toEqual(walletAddressUrl) + }) + + test('tenant creation rolls back if auth tenant create fails', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(() => { + throw new Error() + }) + + expect.assertions(3) + let tenant + try { + tenant = await tenantService.create(createOptions) + } catch (err) { + expect(tenant).toBeUndefined() + + const tenants = await Tenant.query() + expect(tenants.length).toEqual(0) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret + }) + ) + } + }) + }) + + describe('update', (): void => { + test('can update a tenant', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(originalTenantInfo) + + const updatedTenantInfo = { + id: tenant.id, + apiSecret: 'test-api-secret-two', + email: faker.internet.url(), + publicName: 'second test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-two' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => undefined) + const updatedTenant = await tenantService.update(updatedTenantInfo) + + expect(updatedTenant).toEqual(expect.objectContaining(updatedTenantInfo)) + expect(spy).toHaveBeenCalledWith(tenant.id, { + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + }) + }) + + test('rolls back tenant if auth tenant update fails', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + + const tenant = await tenantService.create(originalTenantInfo) + const updatedTenantInfo = { + id: tenant.id, + apiSecret: 'test-api-secret-two', + email: faker.internet.url(), + publicName: 'second test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-two' + } + + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => { + throw new Error() + }) + + let updatedTenant + expect.assertions(3) + try { + updatedTenant = await tenantService.update(updatedTenantInfo) + } catch (err) { + expect(updatedTenant).toBeUndefined() + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant) + expect(dbTenant).toEqual(expect.objectContaining(originalTenantInfo)) + expect(spy).toHaveBeenCalledWith( + tenant.id, + expect.objectContaining({ + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + }) + ) + } + }) + + test('Cannot update deleted tenant', async (): Promise => { + const originalSecret = 'test-secret' + const dbTenant = await Tenant.query(knex).insertAndFetch({ + email: faker.internet.url(), + apiSecret: originalSecret, + idpSecret: 'test-idp-secret', + idpConsentUrl: faker.internet.url(), + deletedAt: new Date() + }) + + const spy = jest.spyOn(authServiceClient.tenant, 'update') + try { + await tenantService.update({ + id: dbTenant.id, + apiSecret: 'test-secret-2' + }) + } catch (err) { + const dbTenantAfterUpdate = await Tenant.query(knex).findById( + dbTenant.id + ) + + assert.ok(dbTenantAfterUpdate) + expect(dbTenantAfterUpdate.apiSecret).toEqual(originalSecret) + expect(spy).toHaveBeenCalledTimes(0) + } + }) + }) + + describe('Delete Tenant', (): void => { + test('Can delete tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(createOptions) + + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => undefined) + await tenantService.delete(tenant.id) + + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant?.deletedAt) + expect(dbTenant.deletedAt.getTime()).toBeLessThanOrEqual( + new Date(Date.now()).getTime() + ) + expect(spy).toHaveBeenCalledWith(tenant.id, dbTenant.deletedAt) + + const settings = (await tenantSettingsService.get({ + tenantId: tenant.id + })) as TenantSetting[] + expect(settings.length).toBe(0) + }) + + test('Reverts deletion if auth tenant delete fails', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(createOptions) + + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => { + throw new Error() + }) + + expect.assertions(3) + try { + await tenantService.delete(tenant.id) + } catch (err) { + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant) + expect(dbTenant.id).toEqual(tenant.id) + expect(dbTenant.deletedAt).toBeNull() + expect(spy).toHaveBeenCalledWith(tenant.id, expect.any(Date)) + } + }) + }) + + describe('Tenant Service using cache', (): void => { + let tenantCache: CacheDataStore + let authServiceClient: AuthServiceClient + + beforeAll(async (): Promise => { + tenantCache = await deps.use('tenantCache') + authServiceClient = await deps.use('authServiceClient') + }) + + describe('create, update, and retrieve tenant using cache', (): void => { + test( + 'Tenant can be created, updated, and fetched', + withConfigOverride( + () => config, + { localCacheDuration: 5_000 }, + async (): Promise => { + const createOptions = { + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: 'test-api-secret', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + + const spyCacheSet = jest.spyOn(tenantCache, 'set') + const tenant = await tenantService.create(createOptions) + expect(tenant).toMatchObject({ + ...createOptions, + id: tenant.id + }) + + // Ensure that the cache was set for create + expect(spyCacheSet).toHaveBeenCalledTimes(1) + + const spyCacheGet = jest.spyOn(tenantCache, 'get') + await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) + + const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + const updatedTenant = await tenantService.update({ + id: tenant.id, + apiSecret: 'test-api-secret-2' + }) + + await expect(tenantService.get(tenant.id)).resolves.toEqual( + updatedTenant + ) + + // Ensure that cache was set for update + expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) + expect(spyCacheUpdateSet).toHaveBeenCalledWith( + tenant.id, + updatedTenant + ) + + const spyCacheDelete = jest.spyOn(tenantCache, 'delete') + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + await tenantService.delete(tenant.id) + + await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() + + // Ensure that cache was set for deletion + expect(spyCacheDelete).toHaveBeenCalledTimes(1) + expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) + + // Ensure 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 new file mode 100644 index 0000000000..0153db25b9 --- /dev/null +++ b/packages/backend/src/tenants/service.ts @@ -0,0 +1,196 @@ +import { Tenant } from './model' +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Pagination, SortOrder } from '../shared/baseModel' +import { CacheDataStore } from '../middleware/cache/data-stores' +import type { AuthServiceClient } from '../auth-service-client/client' +import { TenantSettingService } from './settings/service' +import { TenantSetting } from './settings/model' +import { TenantSettingInput } from '../graphql/generated/graphql' + +export interface TenantService { + get: (id: string, includeDeleted?: boolean) => Promise + create: (options: CreateTenantOptions) => Promise + update: (options: UpdateTenantOptions) => Promise + delete: (id: string) => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise +} + +export interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex + tenantCache: CacheDataStore + authServiceClient: AuthServiceClient + tenantSettingService: TenantSettingService +} + +export async function createTenantService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'TenantService' }) + } + + return { + get: (id: string, includeDeleted?: boolean) => + getTenant(deps, id, includeDeleted), + create: (options) => createTenant(deps, options), + update: (options) => updateTenant(deps, options), + delete: (id) => deleteTenant(deps, id), + getPage: (pagination, sortOrder) => + getTenantPage(deps, pagination, sortOrder) + } +} + +async function getTenant( + deps: ServiceDependencies, + id: string, + includeDeleted: boolean = false +): Promise { + const inMem = await deps.tenantCache.get(id) + 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 +} + +async function getTenantPage( + deps: ServiceDependencies, + pagination?: Pagination, + sortOrder?: SortOrder +): Promise { + return await Tenant.query(deps.knex).getPage(pagination, sortOrder) +} + +interface CreateTenantOptions { + email?: string + apiSecret: string + idpSecret?: string + idpConsentUrl?: string + publicName?: string + settings?: TenantSettingInput[] +} + +async function createTenant( + deps: ServiceDependencies, + options: CreateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + try { + const { email, apiSecret, publicName, idpSecret, idpConsentUrl, settings } = + options + const tenant = await Tenant.query(trx).insertAndFetch({ + email, + publicName, + apiSecret, + idpSecret, + idpConsentUrl + }) + + await deps.authServiceClient.tenant.create({ + id: tenant.id, + idpSecret, + idpConsentUrl + }) + + const createInitialTenantSettingsOptions = { + tenantId: tenant.id, + setting: TenantSetting.default() + } + if (settings) { + createInitialTenantSettingsOptions.setting = + createInitialTenantSettingsOptions.setting.concat(settings) + } + + await deps.tenantSettingService.create(createInitialTenantSettingsOptions, { + trx + }) + + await trx.commit() + + await deps.tenantCache.set(tenant.id, tenant) + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +interface UpdateTenantOptions { + id: string + email?: string + publicName?: string + apiSecret?: string + idpConsentUrl?: string + idpSecret?: string +} + +async function updateTenant( + deps: ServiceDependencies, + options: UpdateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + + try { + const { id, apiSecret, email, publicName, idpConsentUrl, idpSecret } = + options + const tenant = await Tenant.query(trx) + .patchAndFetchById(options.id, { + email, + publicName, + apiSecret, + idpConsentUrl, + idpSecret + }) + .whereNull('deletedAt') + .throwIfNotFound() + + if (idpConsentUrl || idpSecret) { + await deps.authServiceClient.tenant.update(id, { + idpConsentUrl, + idpSecret + }) + } + + await trx.commit() + await deps.tenantCache.set(tenant.id, tenant) + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string +): Promise { + const trx = await deps.knex.transaction() + + await deps.tenantCache.delete(id) + try { + const deletedAt = new Date() + + await deps.tenantSettingService.delete( + { + tenantId: id + }, + { trx, deletedAt } + ) + await Tenant.query(trx).patchAndFetchById(id, { + deletedAt + }) + await deps.authServiceClient.tenant.delete(id, deletedAt) + await trx.commit() + } catch (err) { + await trx.rollback() + throw err + } +} diff --git a/packages/backend/src/tenants/settings/errors.ts b/packages/backend/src/tenants/settings/errors.ts new file mode 100644 index 0000000000..131786e1ef --- /dev/null +++ b/packages/backend/src/tenants/settings/errors.ts @@ -0,0 +1,24 @@ +import { GraphQLErrorCode } from '../../graphql/errors' + +export enum TenantSettingError { + TenantNotFound = 'TenantNotFound', + UnknownError = 'UnknownError' +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isTenantSettingError = (t: any): t is TenantSettingError => + Object.values(TenantSettingError).includes(t) + +export const errorToCode: { + [key in TenantSettingError]: GraphQLErrorCode +} = { + [TenantSettingError.TenantNotFound]: GraphQLErrorCode.NotFound, + [TenantSettingError.UnknownError]: GraphQLErrorCode.InternalServerError +} + +export const errorToMessage: { + [key in TenantSettingError]: string +} = { + [TenantSettingError.TenantNotFound]: 'Tenant not found', + [TenantSettingError.UnknownError]: 'Unknown error' +} diff --git a/packages/backend/src/tenants/settings/model.test.ts b/packages/backend/src/tenants/settings/model.test.ts new file mode 100644 index 0000000000..d220706302 --- /dev/null +++ b/packages/backend/src/tenants/settings/model.test.ts @@ -0,0 +1,12 @@ +import { TenantSetting } from './model' + +describe('TenantSetting Model', (): void => { + describe('defeault', () => { + test('can specify default settings', async (): Promise => { + expect(TenantSetting.default()).toEqual([ + { key: 'WEBHOOK_TIMEOUT', value: '2000' }, + { key: 'WEBHOOK_MAX_RETRY', value: '10' } + ]) + }) + }) +}) diff --git a/packages/backend/src/tenants/settings/model.ts b/packages/backend/src/tenants/settings/model.ts new file mode 100644 index 0000000000..cbba5ac831 --- /dev/null +++ b/packages/backend/src/tenants/settings/model.ts @@ -0,0 +1,44 @@ +import { Pojo } from 'objection' +import { BaseModel } from '../../shared/baseModel' +import { KeyValuePair } from './service' + +export const TenantSettingKeys = { + EXCHANGE_RATES_URL: { name: 'EXCHANGE_RATES_URL' }, + WEBHOOK_URL: { name: 'WEBHOOK_URL' }, + WEBHOOK_TIMEOUT: { name: 'WEBHOOK_TIMEOUT', default: 2000 }, + WEBHOOK_MAX_RETRY: { name: 'WEBHOOK_MAX_RETRY', default: 10 }, + WALLET_ADDRESS_URL: { name: 'WALLET_ADDRESS_URL' } +} + +export class TenantSetting extends BaseModel { + public static get tableName(): string { + return 'tenantSettings' + } + + public key!: string + public value!: string + public tenantId!: string + + public deletedAt?: Date + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + deletedAt: json.deletedAt.toISOString() + } + } + + static default(): KeyValuePair[] { + return [ + { + key: TenantSettingKeys.WEBHOOK_TIMEOUT.name, + value: TenantSettingKeys.WEBHOOK_TIMEOUT.default.toString() + }, + { + key: TenantSettingKeys.WEBHOOK_MAX_RETRY.name, + value: TenantSettingKeys.WEBHOOK_MAX_RETRY.default.toString() + } + ] + } +} diff --git a/packages/backend/src/tenants/settings/service.test.ts b/packages/backend/src/tenants/settings/service.test.ts new file mode 100644 index 0000000000..a95791544e --- /dev/null +++ b/packages/backend/src/tenants/settings/service.test.ts @@ -0,0 +1,299 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' +import { initIocContainer } from '../..' +import { Config } from '../../config/app' +import { createTestApp, TestContainer } from '../../tests/app' +import nock from 'nock' +import { truncateTables } from '../../tests/tableManager' +import { Knex } from 'knex' +import { Tenant } from '../model' +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 { TenantSetting } from './model' +import { + CreateOptions, + GetOptions, + TenantSettingService, + UpdateOptions +} from './service' +import { AuthServiceClient } from '../../auth-service-client/client' + +describe('TenantSetting Service', (): void => { + let knex: Knex + let deps: IocContract + let appContainer: TestContainer + let tenant: Tenant + let tenantService: TenantService + let tenantSettingService: TenantSettingService + let authServiceClient: AuthServiceClient + + const dbSchema = 'tenant_settings_service_test_schema' + + beforeAll(async (): Promise => { + deps = initIocContainer({ ...Config, dbSchema }) + appContainer = await createTestApp(deps) + + knex = await deps.use('knex') + tenantService = await deps.use('tenantService') + tenantSettingService = await deps.use('tenantSettingService') + authServiceClient = await deps.use('authServiceClient') + }) + + beforeEach(async (): Promise => { + jest + .spyOn(authServiceClient.tenant, 'create') + .mockResolvedValueOnce(undefined) + + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockResolvedValueOnce(undefined) + + tenant = await tenantService.create({ + apiSecret: faker.string.uuid(), + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.uuid() + }) + }) + + afterEach(async (): Promise => { + await truncateTables(knex, true, dbSchema) + }) + + afterAll(async (): Promise => { + nock.cleanAll() + await appContainer.shutdown() + }) + + describe('create', () => { + test('can create a tenant setting', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + + expect(tenantSetting).toEqual([ + expect.objectContaining({ + tenantId: tenant.id, + key: createOptions.setting[0].key, + value: createOptions.setting[0].value + }) + ]) + }) + + test('returns empty array if setting key is not allowed', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [randomSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + + expect(tenantSetting).toEqual([]) + }) + }) + + describe('get', () => { + let tenantSetting: TenantSetting[] + + function createTenantSetting() { + const options: CreateOptions = { + tenantId: tenant.id, + setting: [randomSetting()] + } + + return tenantSettingService.create(options) + } + + beforeEach(async (): Promise => { + await createTenantSetting() + tenantSetting = (await tenantSettingService.get({ + tenantId: tenant.id + })) as TenantSetting[] + }) + + afterEach(async (): Promise => { + return tenantSettingService.delete({ + tenantId: tenant.id + }) + }) + + test('should get tenant setting', async () => { + const dbTenantSetting = await tenantSettingService.get({ + tenantId: tenant.id, + key: tenantSetting[0].key + }) + + expect(dbTenantSetting).toEqual([tenantSetting[0]]) + }) + + test('should get all tenant settings', async () => { + const newTenantSetting = await createTenantSetting() + const dbTenantSettings = await tenantSettingService.get({ + tenantId: tenant.id + }) + + const settings = tenantSetting.concat(newTenantSetting) + + expect(dbTenantSettings).toEqual(settings) + }) + + test('should not get deleted tenant', async () => { + const options: GetOptions = { + tenantId: tenant.id, + key: tenantSetting[0].key + } + + await tenantSettingService.delete(options) + const dbTenantSetting = await tenantSettingService.get(options) + + expect(dbTenantSetting).toHaveLength(0) + }) + }) + + describe('update', () => { + let updateOptions: UpdateOptions + + beforeEach(async () => { + updateOptions = { + tenantId: tenant.id, + ...exchangeRatesSetting() + } + + await tenantSettingService.create({ + tenantId: updateOptions.tenantId, + setting: [{ key: updateOptions.key, value: updateOptions.value }] + }) + }) + + test('can update own setting', async () => { + const newValues = { + ...updateOptions, + value: 'test' + } + await tenantSettingService.update(newValues) + + const res = await tenantSettingService.get({ + tenantId: newValues.tenantId, + key: newValues.key + }) + + expect(res).toEqual( + expect.arrayContaining([expect.objectContaining(newValues)]) + ) + }) + }) + + describe('delete', (): 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') + + for (const tenantSetting of found?.settings as TenantSetting[]) { + expect(found?.deletedAt).toEqual(tenantSetting.deletedAt) + } + }) + }) + test('can delete tenant setting key', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + await tenantSettingService.delete({ + tenantId: tenantSetting[0].tenantId, + key: createOptions.setting[0].key + }) + + const dbTenantSetting = await TenantSetting.query().findById( + tenantSetting[0].id + ) + expect(dbTenantSetting?.deletedAt).toBeDefined() + expect(dbTenantSetting?.deletedAt?.getTime()).toBeLessThanOrEqual( + Date.now() + ) + }) + + test('cannot delete already deleted setting', async (): Promise => { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + } + + const tenantSetting = await tenantSettingService.create(createOptions) + await tenantSettingService.delete({ + tenantId: tenantSetting[0].tenantId, + key: createOptions.setting[0].key + }) + + let dbTenantSetting = await TenantSetting.query().findById( + tenantSetting[0].id + ) + expect(dbTenantSetting?.deletedAt).toBeDefined() + + const originalDeletedAt = dbTenantSetting?.deletedAt + await tenantSettingService.delete({ + tenantId: tenantSetting[0].tenantId, + key: createOptions.setting[0].key + }) + + dbTenantSetting = await TenantSetting.query().findById( + tenantSetting[0].id + ) + expect(dbTenantSetting?.deletedAt).toBeDefined() + + expect(originalDeletedAt?.getTime()).toEqual( + dbTenantSetting?.deletedAt?.getTime() + ) + }) + + test('can delete all tenant settings', async (): Promise => { + for (let i = 0; i < 10; i++) { + const createOptions: CreateOptions = { + tenantId: tenant.id, + setting: [randomSetting()] + } + + await tenantSettingService.create(createOptions) + } + + await tenantSettingService.delete({ tenantId: tenant.id }) + + const dbTenantData = await TenantSetting.query().where({ + tenantId: tenant.id + }) + + expect(dbTenantData.filter((x) => !x.deletedAt)).toHaveLength(0) + }) + }) + + describe('pagination', (): void => { + beforeEach(async () => { + await tenantSettingService.delete({ tenantId: tenant.id }) + }) + describe('getPage', (): void => { + getPageTests({ + createModel: () => + createTenantSettings(deps, { + tenantId: tenant.id, + setting: [exchangeRatesSetting()] + }) as Promise, + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => + tenantSettingService.getPage(tenant.id, pagination, sortOrder) + }) + }) + }) +}) diff --git a/packages/backend/src/tenants/settings/service.ts b/packages/backend/src/tenants/settings/service.ts new file mode 100644 index 0000000000..c12caa0d1e --- /dev/null +++ b/packages/backend/src/tenants/settings/service.ts @@ -0,0 +1,149 @@ +import { TransactionOrKnex } from 'objection' +import { Pagination, SortOrder } from '../../shared/baseModel' +import { BaseService } from '../../shared/baseService' +import { TenantSetting, TenantSettingKeys } from './model' +import { Knex } from 'knex' + +export interface KeyValuePair { + key: string + value: string +} + +export interface UpdateOptions { + tenantId: string + key: string + value: string +} + +export interface CreateOptions { + tenantId: string + setting: KeyValuePair[] +} + +export interface GetOptions { + tenantId: string + key?: string +} + +export interface ExtraOptions { + trx?: Knex.Transaction + deletedAt?: Date +} + +export interface TenantSettingService { + get: (options: GetOptions) => Promise + create: ( + options: CreateOptions, + extra?: ExtraOptions + ) => Promise + update: (options: UpdateOptions) => Promise + delete: (options: GetOptions, extra?: ExtraOptions) => Promise + getPage: ( + tenantId: string, + pagination?: Pagination, + sortOrder?: SortOrder + ) => Promise +} + +export interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createTenantSettingService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'TenantSettingService ' }) + } + + return { + get: (options: GetOptions) => getTenantSettings(deps, options), + create: (options: CreateOptions, extra?: ExtraOptions) => + createTenantSetting(deps, options, extra), + update: (options: UpdateOptions) => updateTenantSetting(deps, options), + delete: (options: GetOptions, extra?: ExtraOptions) => + deleteTenantSetting(deps, options, extra), + getPage: ( + tenantId: string, + pagination?: Pagination, + sortOrder?: SortOrder + ) => getTenantSettingPageForTenant(deps, tenantId, pagination, sortOrder) + } +} + +async function getTenantSettings( + deps: ServiceDependencies, + options: GetOptions +): Promise { + return TenantSetting.query(deps.knex).whereNull('deletedAt').andWhere(options) +} + +async function deleteTenantSetting( + deps: ServiceDependencies, + options: GetOptions, + extra?: ExtraOptions +) { + const obj: GetOptions = { + tenantId: options.tenantId + } + + if (options.key) { + obj.key = options.key + } + + await TenantSetting.query(extra?.trx ?? deps.knex) + .findOne(obj) + .whereNull('deletedAt') + .patch({ + deletedAt: extra?.deletedAt ?? new Date() + }) +} + +async function updateTenantSetting( + deps: ServiceDependencies, + options: UpdateOptions +): Promise { + await TenantSetting.query(deps.knex) + .patch({ value: options.value }) + .whereNull('deletedAt') + .andWhere('tenantId', options.tenantId) + .andWhere('key', options.key) + .returning('*') + .throwIfNotFound() +} + +async function createTenantSetting( + deps: ServiceDependencies, + options: CreateOptions, + extra?: ExtraOptions +) { + const dataToUpsert = options.setting + .filter((setting) => Object.keys(TenantSettingKeys).includes(setting.key)) + .map((s) => ({ + tenantId: options.tenantId, + ...s + })) + + if (Object.keys(dataToUpsert).length <= 0) { + return [] + } + + return TenantSetting.query(extra?.trx ?? deps.knex) + .insert(dataToUpsert) + .onConflict(['tenantId', 'key']) + .merge() + .returning('*') +} + +async function getTenantSettingPageForTenant( + deps: ServiceDependencies, + tenantId: string, + pagination?: Pagination, + sortOrder?: SortOrder +): Promise { + return await TenantSetting.query(deps.knex) + .whereNull('deletedAt') + .andWhere('tenantId', tenantId) + .getPage(pagination, sortOrder) +} diff --git a/packages/backend/src/tests/app.ts b/packages/backend/src/tests/app.ts index cbe82b4704..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 @@ -79,14 +56,15 @@ export const createTestApp = async ( const authLink = setContext((_, { headers }) => { return { headers: { - ...headers + ...headers, + '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: { @@ -101,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/asset.ts b/packages/backend/src/tests/asset.ts index daab8992be..63dd3994bc 100644 --- a/packages/backend/src/tests/asset.ts +++ b/packages/backend/src/tests/asset.ts @@ -25,10 +25,16 @@ export function randomLedger(): number { export async function createAsset( deps: IocContract, - options?: AssetOptions + options?: AssetOptions, + tenantId?: string ): Promise { + const config = await deps.use('config') const assetService = await deps.use('assetService') - const assetOrError = await assetService.create(options || randomAsset()) + const createOptions = options || randomAsset() + const assetOrError = await assetService.create({ + ...createOptions, + tenantId: tenantId ? tenantId : config.operatorTenantId + }) if (isAssetError(assetOrError)) { throw assetOrError } diff --git a/packages/backend/src/tests/combinedPayment.ts b/packages/backend/src/tests/combinedPayment.ts index 504aceeb97..fbae2901b7 100644 --- a/packages/backend/src/tests/combinedPayment.ts +++ b/packages/backend/src/tests/combinedPayment.ts @@ -40,19 +40,25 @@ export async function createCombinedPayment( const sendAsset = await createAsset(deps) const receiveAsset = await createAsset(deps) const sendWalletAddressId = ( - await createWalletAddress(deps, { assetId: sendAsset.id }) + await createWalletAddress(deps, { + assetId: sendAsset.id, + tenantId: sendAsset.tenantId + }) ).id const receiveWalletAddress = await createWalletAddress(deps, { - assetId: receiveAsset.id + assetId: receiveAsset.id, + tenantId: sendAsset.tenantId }) const type = Math.random() < 0.5 ? PaymentType.Incoming : PaymentType.Outgoing 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/tests/tableManager.ts b/packages/backend/src/tests/tableManager.ts index 26f07d5d2d..9467127684 100644 --- a/packages/backend/src/tests/tableManager.ts +++ b/packages/backend/src/tests/tableManager.ts @@ -10,21 +10,28 @@ export async function truncateTable( export async function truncateTables( knex: Knex, - ignoreTables = [ + truncateTenants = false, + dbSchema?: string +): Promise { + const ignoreTables = [ 'knex_migrations', 'knex_migrations_lock', 'knex_migrations_backend', - 'knex_migrations_backend_lock' + 'knex_migrations_backend_lock', + ...(truncateTenants ? [] : ['tenants']) // So we don't delete operator tenant ] -): Promise { - const tables = await getTables(knex, ignoreTables) + const tables = await getTables(knex, dbSchema, ignoreTables) const RAW = `TRUNCATE TABLE "${tables}" RESTART IDENTITY` await knex.raw(RAW) } -async function getTables(knex: Knex, ignoredTables: string[]): Promise { +async function getTables( + knex: Knex, + dbSchema: string = 'public', + ignoredTables: string[] +): Promise { const result = await knex.raw( - "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'" + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='${dbSchema}'` ) return result.rows .map((val: { tablename: string }) => { diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts new file mode 100644 index 0000000000..d8874b4573 --- /dev/null +++ b/packages/backend/src/tests/tenant.ts @@ -0,0 +1,48 @@ +import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' +import { AppServices } from '../app' +import { Tenant } from '../tenants/model' + +interface CreateOptions { + email: string + publicName?: string + apiSecret: string + idpConsentUrl: string + idpSecret: string +} + +export function generateTenantInput() { + return { + email: faker.internet.email(), + apiSecret: faker.string.alphanumeric(8), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(8), + publicName: faker.company.name() + } +} + +export async function createTenant( + deps: IocContract, + options?: CreateOptions +): Promise { + const tenantService = await deps.use('tenantService') + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create( + options || { + email: faker.internet.email(), + apiSecret: 'test-api-secret', + publicName: faker.company.name(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + ) + + if (!tenant) { + throw Error('Failed to create test tenant') + } + + return tenant +} diff --git a/packages/backend/src/tests/tenantSettings.ts b/packages/backend/src/tests/tenantSettings.ts new file mode 100644 index 0000000000..b678cc4ec1 --- /dev/null +++ b/packages/backend/src/tests/tenantSettings.ts @@ -0,0 +1,34 @@ +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../app' +import { TenantSetting } from '../tenants/settings/model' +import { CreateOptions, KeyValuePair } from '../tenants/settings/service' +import { faker } from '@faker-js/faker' +import { isTenantSettingError } from '../tenants/settings/errors' + +export function randomSetting(): KeyValuePair { + return { + key: faker.string.alphanumeric({ + length: { min: 10, max: 20 } + }), + value: faker.string.uuid() + } +} + +export function exchangeRatesSetting(): KeyValuePair { + return { + key: 'EXCHANGE_RATES_URL', + value: faker.internet.url() + } +} + +export async function createTenantSettings( + deps: IocContract, + options: CreateOptions +): Promise { + const tenantSettingService = await deps.use('tenantSettingService') + const tenantSettingOrError = await tenantSettingService.create(options) + if (isTenantSettingError(tenantSettingOrError)) { + throw tenantSettingOrError + } + return tenantSettingOrError[0] +} diff --git a/packages/backend/src/tests/walletAddress.ts b/packages/backend/src/tests/walletAddress.ts index 3149d73a56..6b84abda64 100644 --- a/packages/backend/src/tests/walletAddress.ts +++ b/packages/backend/src/tests/walletAddress.ts @@ -6,6 +6,7 @@ import { URL } from 'url' import { testAccessToken } from './app' import { createAsset } from './asset' +import { createTenant } from './tenant' import { AppServices } from '../app' import { isWalletAddressError } from '../open_payments/wallet_address/errors' import { WalletAddress } from '../open_payments/wallet_address/model' @@ -29,10 +30,15 @@ export async function createWalletAddress( options: Partial = {} ): Promise { const walletAddressService = await deps.use('walletAddressService') + const tenantIdToUse = options.tenantId || (await createTenant(deps)).id const walletAddressOrError = (await walletAddressService.create({ ...options, - assetId: options.assetId || (await createAsset(deps)).id, - url: options.url || `https://${faker.internet.domainName()}/.well-known/pay` + assetId: + options.assetId || (await createAsset(deps, undefined, tenantIdToUse)).id, + tenantId: tenantIdToUse, + address: + options.address || + `https://${faker.internet.domainName()}/.well-known/pay` })) as MockWalletAddress if (isWalletAddressError(walletAddressOrError)) { throw new Error(walletAddressOrError) @@ -48,7 +54,7 @@ export async function createWalletAddress( ) } if (options.mockServerPort) { - const url = new URL(walletAddressOrError.url) + const url = new URL(walletAddressOrError.address) walletAddressOrError.scope = nock(url.origin) .get((uri) => uri.startsWith(url.pathname)) .matchHeader('Accept', /application\/((ilp-stream|spsp4)\+)?json*./) diff --git a/packages/backend/src/webhook/service.test.ts b/packages/backend/src/webhook/service.test.ts index dee8aee3ef..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,17 +118,24 @@ describe('Webhook Service', (): void => { let events: WebhookEvent[] = [] beforeEach(async (): Promise => { - walletAddressIn = await createWalletAddress(deps) - walletAddressOut = await createWalletAddress(deps) + tenantId = Config.operatorTenantId + walletAddressIn = await createWalletAddress(deps, { + tenantId + }) + walletAddressOut = await createWalletAddress(deps, { + 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 ] @@ -135,6 +143,7 @@ describe('Webhook Service', (): void => { ( await createOutgoingPayment(deps, { method: 'ilp', + tenantId, walletAddressId: walletAddressOut.id, receiver: '', validDestination: false @@ -143,6 +152,7 @@ describe('Webhook Service', (): void => { ( await createOutgoingPayment(deps, { method: 'ilp', + tenantId, walletAddressId: walletAddressOut.id, receiver: '', validDestination: false diff --git a/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx b/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx index bf5ac262e6..21c12ee0bb 100644 --- a/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx +++ b/packages/documentation/src/content/docs/integration/deployment/docker-compose.mdx @@ -112,6 +112,7 @@ services: AUTH_PORT: 3006 INTROSPECTION_PORT: 3007 INTERACTION_PORT: 3009 + SERVICE_API_PORT: 3011 COOKIE_KEY: {...} IDENTITY_SERVER_SECRET: {...} IDENTITY_SERVER_URL: {https://idp.mysystem.com} @@ -126,6 +127,7 @@ services: - '3006:3006' - '3007:3007' - '3009:3009' + - '3011:3011' restart: always rafiki-backend: @@ -137,6 +139,7 @@ services: environment: AUTH_SERVER_GRANT_URL: {https://auth.myrafiki.com} AUTH_SERVER_INTROSPECTION_URL: {https://auth.myrafiki.com/3007} + AUTH_SERVICE_API_URL: {https://auth.myrafiki.com/3011} DATABASE_URL: {postgresql://...} ILP_ADDRESS: {test.myrafiki} ADMIN_PORT: 3001 diff --git a/packages/documentation/src/partials/auth-variables.mdx b/packages/documentation/src/partials/auth-variables.mdx index b8af51568a..ef8d52955b 100644 --- a/packages/documentation/src/partials/auth-variables.mdx +++ b/packages/documentation/src/partials/auth-variables.mdx @@ -33,6 +33,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `INTERACTION_EXPIRY_SECONDS` | `auth.interactionExpirySeconds` | `600` (10 minutes) | The time, in seconds, for which a user can interact with a grant request before the request expires. | | `INTERACTION_PORT` | `auth.port.interaction` | `3009` | The port number of your Open Payments interaction-related APIs. | | `INTROSPECTION_PORT` | `auth.port.introspection` | `3007` | The port of your Open Payments access token introspection server. | +| `SERVICE_API_PORT` | `auth.port.serviceAPIPort` | `3011` | The port to expose the internal service api. | | `LIST_ALL_ACCESS_INTERACTION` | `auth.interaction.listAll` | `true` | When `true`, grant requests that include a `list-all` action will require interaction. In these requests, the client asks to list resources that it did not create. | | `LOG_LEVEL` | `auth.logLevel` | `info` | Pino log level | | `NODE_ENV` | `auth.nodeEnv` | `development` | The type of node environment: `development`, `test`, or `production`. | diff --git a/packages/documentation/src/partials/backend-variables.mdx b/packages/documentation/src/partials/backend-variables.mdx index 68fdb527b0..ce6f576811 100644 --- a/packages/documentation/src/partials/backend-variables.mdx +++ b/packages/documentation/src/partials/backend-variables.mdx @@ -17,6 +17,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `REDIS_URL` | `backend.redis.host`,
`backend.redis.port` | `redis://127.0.0.1:6379` | The Redis URL of the database handling ILP packet data. For Helm, these components are provided individually. | | `USE_TIGERBEETLE` | `backend.use.tigerbeetle` | `true` | When `true`, a TigerBeetle database is used for accounting. When `false`, a Postgres database is used. | | `WEBHOOK_URL` | `backend.serviceUrls.WEBHOOK_URL` | _undefined_ | Your endpoint that consumes webhook events. | +| `AUTH_SERVICE_API_URL` | `backend.serviceUrls.AUTH_SERVICE_API_URL` | _undefined_ | The service-to-service api endpoint on your Open Payments authorization server. | diff --git a/packages/frontend/app/components/ApiCredentialsForm.tsx b/packages/frontend/app/components/ApiCredentialsForm.tsx new file mode 100644 index 0000000000..9f42409025 --- /dev/null +++ b/packages/frontend/app/components/ApiCredentialsForm.tsx @@ -0,0 +1,113 @@ +import { Form, useActionData, useNavigation } from '@remix-run/react' +import { useRef, useState, useEffect } from 'react' +import { Input, Button } from '~/components/ui' +import { validate as validateUUID } from 'uuid' + +interface ApiCredentialsFormProps { + showClearCredentials: boolean + defaultTenantId: string + defaultApiSecret: string +} + +interface ActionErrorResponse { + status: number + statusText: string +} + +export const ApiCredentialsForm = ({ + showClearCredentials, + defaultTenantId, + defaultApiSecret +}: ApiCredentialsFormProps) => { + const actionData = useActionData() + const navigation = useNavigation() + const inputRef = useRef(null) + const formRef = useRef(null) + const [tenantIdError, setTenantIdError] = useState(null) + + const isSubmitting = navigation.state === 'submitting' + + const handleTenantIdChange = (event: React.ChangeEvent) => { + const tenantId = event.target.value.trim() + + if (tenantId === '') { + setTenantIdError('Tenant ID is required') + } else if (!validateUUID(tenantId)) { + setTenantIdError('Invalid Tenant ID (must be a valid UUID)') + } else { + setTenantIdError(null) + } + } + + // auto submit form if values passed in + useEffect(() => { + if (defaultTenantId && defaultApiSecret && !tenantIdError) { + if (formRef.current) { + formRef.current.submit() + } + } + }, [defaultTenantId, defaultApiSecret, tenantIdError]) + + return ( +
+ {showClearCredentials ? ( +
+

✓ API credentials configured

+ + +
+ ) : ( +
+ + {tenantIdError && ( +

+ {tenantIdError} +

+ )} + + +
+ +
+
+ )} + {actionData?.statusText && ( +
{actionData.statusText}
+ )} +
+ ) +} diff --git a/packages/frontend/app/components/Sidebar.tsx b/packages/frontend/app/components/Sidebar.tsx index 5b140a25ca..5e37ef29d5 100644 --- a/packages/frontend/app/components/Sidebar.tsx +++ b/packages/frontend/app/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { Button } from '~/components/ui' interface SidebarProps { logoutUrl: string authEnabled: boolean + hasApiCredentials: boolean } const navigation = [ @@ -16,6 +17,10 @@ const navigation = [ name: 'Home', href: '/' }, + { + name: 'Tenants', + href: '/tenants' + }, { name: 'Assets', href: '/assets' @@ -38,9 +43,17 @@ const navigation = [ } ] -export const Sidebar: FC = ({ logoutUrl, authEnabled }) => { +export const Sidebar: FC = ({ + logoutUrl, + authEnabled, + hasApiCredentials +}) => { const [sidebarIsOpen, setSidebarIsOpen] = useState(false) + const navigationToShow = hasApiCredentials + ? navigation + : navigation.filter(({ name }) => name === 'Home') + return ( <> @@ -81,7 +94,7 @@ export const Sidebar: FC = ({ logoutUrl, authEnabled }) => {