From ea7b2f759236f4eeeb424ee7fd4bb564d29236dc Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 26 Nov 2024 12:06:28 -0800 Subject: [PATCH 01/47] feat(backend): tenants table v1 (#3132) --- .../20241125224212_create_tenants_table.js | 20 +++++++++++++++++++ packages/backend/src/tenants/model.ts | 11 ++++++++++ 2 files changed, 31 insertions(+) create mode 100644 packages/backend/migrations/20241125224212_create_tenants_table.js create mode 100644 packages/backend/src/tenants/model.ts 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..2b00eb981d --- /dev/null +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -0,0 +1,20 @@ +/** + * @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').notNullable() + table.string('publicName') + table.string('apiSecret') + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('tenants') +} diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts new file mode 100644 index 0000000000..e1347fff7f --- /dev/null +++ b/packages/backend/src/tenants/model.ts @@ -0,0 +1,11 @@ +import { BaseModel } from '../shared/baseModel' + +export class Tenant extends BaseModel { + public static get tableName(): string { + return 'tenants' + } + + public email!: string + public apiSecret!: string + public publicName?: string +} From ea7e6603a24c4dc625b8776bed11b142004b7906 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Mon, 2 Dec 2024 11:13:07 -0800 Subject: [PATCH 02/47] feat(auth): tenants table v1 (#3133) * feat(auth): tenants table v1 * fix: add updatedAt, createdAt * feat: add deletedAt --- .../20241125233415_create_tenants_table.js | 23 +++++++++++++++++++ packages/auth/src/tenant/model.ts | 12 ++++++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/auth/migrations/20241125233415_create_tenants_table.js create mode 100644 packages/auth/src/tenant/model.ts 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..765b6ea9f8 --- /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').notNullable() + table.string('idpSecret').notNullable() + + 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) { + knex.schema.dropTableIfExists('tenants') +} diff --git a/packages/auth/src/tenant/model.ts b/packages/auth/src/tenant/model.ts new file mode 100644 index 0000000000..b135541c34 --- /dev/null +++ b/packages/auth/src/tenant/model.ts @@ -0,0 +1,12 @@ +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 +} From 349b01ec11f096f09c734ceb204265b94de6aab6 Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Tue, 3 Dec 2024 09:02:30 -0500 Subject: [PATCH 03/47] feat(auth): tenant service (#3144) * feat(auth): tenant service * chore(auth): format * fix(auth): jest test warning about migration * fix(auth): remove temporary code * feat(auth): soft delete tenants * fix(auth): return erroneously removed tests --- .../20241125233415_create_tenants_table.js | 2 +- packages/auth/src/app.ts | 2 + packages/auth/src/index.ts | 11 ++ packages/auth/src/tenant/service.test.ts | 166 ++++++++++++++++++ packages/auth/src/tenant/service.ts | 82 +++++++++ 5 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 packages/auth/src/tenant/service.test.ts create mode 100644 packages/auth/src/tenant/service.ts diff --git a/packages/auth/migrations/20241125233415_create_tenants_table.js b/packages/auth/migrations/20241125233415_create_tenants_table.js index 765b6ea9f8..9112108977 100644 --- a/packages/auth/migrations/20241125233415_create_tenants_table.js +++ b/packages/auth/migrations/20241125233415_create_tenants_table.js @@ -19,5 +19,5 @@ exports.up = function (knex) { * @returns { Promise } */ exports.down = function (knex) { - knex.schema.dropTableIfExists('tenants') + return knex.schema.dropTableIfExists('tenants') } diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index b8bd12a938..dba8efebda 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 diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 2315fe083b..356b321cf1 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -21,6 +21,7 @@ import { import { createInteractionService } from './interaction/service' import { getTokenIntrospectionOpenAPI } from 'token-introspection' import { Redis } from 'ioredis' +import { createTenantService } from './tenant/service' const container = initIocContainer(Config) const app = new App(container) @@ -209,6 +210,16 @@ export function initIocContainer( return new Redis(config.redisUrl, { tls: config.redisTls }) }) + container.singleton( + 'tenantService', + async (deps: IocContract) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + } + ) + return container } diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts new file mode 100644 index 0000000000..fa553b7b18 --- /dev/null +++ b/packages/auth/src/tenant/service.test.ts @@ -0,0 +1,166 @@ +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).toMatchObject({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret + }) + expect(tenant.deletedAt).toBe(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).toMatchObject(tenantData) + }) + + 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) + + 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).toMatchObject({ + id: created.id, + ...updateData + }) + }) + + 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).toMatchObject({ + id: created.id, + idpConsentUrl: updateData.idpConsentUrl, + idpSecret: created.idpSecret + }) + }) + + 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) + + 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) + 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()) + 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) + const secondDelete = await tenantService.delete(created.id) + 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..d8d9f2a24c --- /dev/null +++ b/packages/auth/src/tenant/service.ts @@ -0,0 +1,82 @@ +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): 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) => deleteTenant(deps, id) + } +} + +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 +): Promise { + const deleted = await Tenant.query(deps.knex) + .patch({ deletedAt: new Date() }) + .whereNull('deletedAt') + .where('id', id) + return deleted > 0 +} From 07630c1004fb22bd5da413d009a6610822ed694c Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Mon, 9 Dec 2024 09:32:55 -0800 Subject: [PATCH 04/47] feat(backend): tenants service (#3123) (#3140) * feat(backend): tenant service * fix: integration tests * feat: use soft delete * refactor: compare whole object in test * fix: better gql errors in tests * feat: add idp columns to tenant model * feat: pagination tests, push deletedAt to auth api call * feat: add cache * fix: update localenv environment variables * feat: make some tenants fields optional, small refactors --- localenv/cloud-nine-wallet/docker-compose.yml | 2 + localenv/happy-life-bank/docker-compose.yml | 2 + packages/backend/jest.config.js | 2 + .../20241125224212_create_tenants_table.js | 10 +- packages/backend/package.json | 2 +- packages/backend/src/config/app.ts | 3 + packages/backend/src/index.ts | 98 ++++ packages/backend/src/tenants/model.ts | 13 + packages/backend/src/tenants/service.test.ts | 463 ++++++++++++++++++ packages/backend/src/tenants/service.ts | 204 ++++++++ packages/backend/src/tests/tenant.ts | 41 ++ pnpm-lock.yaml | 6 +- .../cloud-nine-wallet/docker-compose.yml | 2 + .../happy-life-bank/docker-compose.yml | 2 + 14 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/tenants/service.test.ts create mode 100644 packages/backend/src/tenants/service.ts create mode 100644 packages/backend/src/tests/tenant.ts diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index b0a75933d8..a493be4b71 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -65,6 +65,8 @@ 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=' ILP_ADDRESS: ${ILP_ADDRESS:-test.cloud-nine-wallet} STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index e72e1654bd..93475143f1 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -58,6 +58,8 @@ 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=' ILP_ADDRESS: test.happy-life-bank ILP_CONNECTOR_URL: http://happy-life-bank-backend:4002 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= diff --git a/packages/backend/jest.config.js b/packages/backend/jest.config.js index 492a6e5e30..c0245a4d3b 100644 --- a/packages/backend/jest.config.js +++ b/packages/backend/jest.config.js @@ -12,6 +12,8 @@ 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_ADMIN_API_URL = 'http://127.0.0.1:3003/graphql' +process.env.AUTH_ADMIN_API_SECRET = 'test-secret' process.env.WEBHOOK_URL = 'http://127.0.0.1:4001/webhook' process.env.STREAM_SECRET = '2/PxuRFV9PAp0yJlnAifJ+1OxujjjI16lN+DBnLNRLA=' process.env.USE_TIGERBEETLE = false diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js index 2b00eb981d..e6fc77e934 100644 --- a/packages/backend/migrations/20241125224212_create_tenants_table.js +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -5,9 +5,15 @@ exports.up = function (knex) { return knex.schema.createTable('tenants', function (table) { table.uuid('id').notNullable().primary() - table.string('email').notNullable() + table.string('email') + table.string('apiSecret').notNullable() + table.string('idpConsentUrl') + table.string('idpSecret') table.string('publicName') - table.string('apiSecret') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') }) } diff --git a/packages/backend/package.json b/packages/backend/package.json index 9c515931dd..5e0d1edc6a 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/config/app.ts b/packages/backend/src/config/app.ts index b266c062ac..2f70ab2164 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -126,6 +126,9 @@ 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), outgoingPaymentWorkers: envInt('OUTGOING_PAYMENT_WORKERS', 1), outgoingPaymentWorkerIdle: envInt('OUTGOING_PAYMENT_WORKER_IDLE', 10), // milliseconds diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 91db346566..b167410756 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,7 @@ import { } from './telemetry/service' import { createWebhookService } from './webhook/service' import { createInMemoryDataStore } from './middleware/cache/data-stores/in-memory' +import { createTenantService } from './tenants/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -131,6 +144,91 @@ 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 = Math.round(new Date().getTime() / 1000) + 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('tenantService', async (deps) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + apolloClient: await deps.use('apolloClient'), + tenantCache: await deps.use('tenantCache') + }) + }) + container.singleton('ratesService', async (deps) => { const config = await deps.use('config') return createRatesService({ diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts index e1347fff7f..78b7a16139 100644 --- a/packages/backend/src/tenants/model.ts +++ b/packages/backend/src/tenants/model.ts @@ -1,4 +1,5 @@ import { BaseModel } from '../shared/baseModel' +import { Pojo } from 'objection' export class Tenant extends BaseModel { public static get tableName(): string { @@ -7,5 +8,17 @@ export class Tenant extends BaseModel { public email!: string public apiSecret!: string + public idpConsentUrl!: string + public idpSecret!: string public publicName?: string + + 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..84fad049ca --- /dev/null +++ b/packages/backend/src/tenants/service.test.ts @@ -0,0 +1,463 @@ +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 { ApolloClient, NormalizedCacheObject } from '@apollo/client' +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' + +const generateMutateGqlError = (path: string = 'createTenant') => ({ + errors: [ + { + message: 'invalid input syntax', + locations: [ + { + line: 1, + column: 1 + } + ], + path: [path], + extensions: { + code: 'INTERNAl_SERVER_ERROR' + } + } + ], + data: null +}) + +describe('Tenant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantService: TenantService + let config: IAppConfig + let apolloClient: ApolloClient + let knex: Knex + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + config = await deps.use('config') + apolloClient = await deps.use('apolloClient') + knex = await deps.use('knex') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + nock.cleanAll() + await appContainer.shutdown() + }) + + describe('Tenant pangination', (): 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 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() + }) + }) + + 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 scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const tenant = await tenantService.create(createOptions) + + expect(tenant).toEqual(expect.objectContaining(createOptions)) + + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpSecret: createOptions.idpSecret, + idpConsentUrl: createOptions.idpConsentUrl + } + } + }) + ) + + scope.done() + }) + + 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 scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('createTenant')) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + let tenant + try { + tenant = await tenantService.create(createOptions) + } catch (err) { + expect(tenant).toBeUndefined() + + const tenants = await Tenant.query() + expect(tenants.length).toEqual(0) + + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: expect.any(String), + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret + } + } + }) + ) + } + scope.done() + }) + }) + + 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' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + .persist() + 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 apolloSpy = jest.spyOn(apolloClient, 'mutate') + const updatedTenant = await tenantService.update(updatedTenantInfo) + + expect(updatedTenant).toEqual(expect.objectContaining(updatedTenantInfo)) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + } + } + }) + ) + scope.done() + }) + + 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' + } + + nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + 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' + } + + nock.cleanAll() + + nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('updateTenant')) + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + let updatedTenant + 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(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + } + } + }) + ) + } + + nock.cleanAll() + }) + + 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 apolloSpy = jest.spyOn(apolloClient, 'mutate') + 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(apolloSpy).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' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + .persist() + const tenant = await tenantService.create(createOptions) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + await tenantService.delete(tenant.id) + + const dbTenant = await Tenant.query().findById(tenant.id) + expect(dbTenant?.deletedAt?.getTime()).toBeLessThanOrEqual( + new Date(Date.now()).getTime() + ) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { id: tenant.id, deletedAt: dbTenant?.deletedAt } + } + }) + ) + + scope.done() + }) + + 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' + } + + nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + const tenant = await tenantService.create(createOptions) + + nock.cleanAll() + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const deleteScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('deleteTenant')) + 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(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + deletedAt: expect.any(Date) + } + } + }) + ) + } + + deleteScope.done() + }) + }) + + describe('Tenant Service using cache', (): void => { + let deps: IocContract + let appContainer: TestContainer + let config: IAppConfig + let tenantService: TenantService + let tenantCache: CacheDataStore + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + localCacheDuration: 5_000 // 5-second default. + }) + appContainer = await createTestApp(deps) + config = await deps.use('config') + tenantService = await deps.use('tenantService') + tenantCache = await deps.use('tenantCache') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create, update, and retrieve tenant using cache', (): void => { + test('Tenant can be created, updated, and fetched', async (): Promise => { + const createOptions = { + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: 'test-api-secret', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { tenant: { id: 1234 } } } }) + .persist() + + 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') + 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') + 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) + + scope.done() + }) + }) + }) +}) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts new file mode 100644 index 0000000000..d1973471eb --- /dev/null +++ b/packages/backend/src/tenants/service.ts @@ -0,0 +1,204 @@ +import { Tenant } from './model' +import { BaseService } from '../shared/baseService' +import { gql, NormalizedCacheObject } from '@apollo/client' +import { ApolloClient } from '@apollo/client' +import { TransactionOrKnex } from 'objection' +import { Pagination, SortOrder } from '../shared/baseModel' +import { CacheDataStore } from '../middleware/cache/data-stores' + +export interface TenantService { + get: (id: string) => 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 + apolloClient: ApolloClient + tenantCache: CacheDataStore +} + +export async function createTenantService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'TenantService' }) + } + + return { + get: (id: string) => getTenant(deps, id), + 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 +): Promise { + const inMem = await deps.tenantCache.get(id) + if (inMem) return inMem + const tenant = await Tenant.query(deps.knex) + .findById(id) + .whereNull('deletedAt') + 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 +} + +async function createTenant( + deps: ServiceDependencies, + options: CreateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + try { + const { email, apiSecret, publicName, idpSecret, idpConsentUrl } = options + const tenant = await Tenant.query(trx).insertAndFetch({ + email, + publicName, + apiSecret, + idpSecret, + idpConsentUrl + }) + + const mutation = gql` + mutation CreateAuthTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + } + } + } + ` + + const variables = { + input: { + id: tenant.id, + idpSecret, + idpConsentUrl + } + } + + // TODO: add type to this in https://github.com/interledger/rafiki/issues/3125 + await deps.apolloClient.mutate({ mutation, variables }) + 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) { + const mutation = gql` + mutation UpdateAuthTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + } + } + } + ` + + const variables = { + input: { + id, + idpConsentUrl, + idpSecret + } + } + + // TODO: add types to this in https://github.com/interledger/rafiki/issues/3125 + await deps.apolloClient.mutate({ mutation, variables }) + } + + 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 Tenant.query(trx).patchAndFetchById(id, { + deletedAt + }) + const mutation = gql` + mutation DeleteAuthTenantMutation($input: DeleteTenantInput!) { + deleteTenant(input: $input) { + sucess + } + } + ` + const variables = { input: { id, deletedAt } } + // TODO: add types to this in https://github.com/interledger/rafiki/issues/3125 + await deps.apolloClient.mutate({ mutation, variables }) + await trx.commit() + } catch (err) { + await trx.rollback() + throw err + } +} diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts new file mode 100644 index 0000000000..4ac1488b84 --- /dev/null +++ b/packages/backend/src/tests/tenant.ts @@ -0,0 +1,41 @@ +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 +} + +const nock = (global as unknown as { nock: typeof import('nock') }).nock + +export async function createTenant( + deps: IocContract, + options?: CreateOptions +): Promise { + const tenantService = await deps.use('tenantService') + const config = await deps.use('config') + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + 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' + } + ) + scope.done() + + if (!tenant) { + throw Error('Failed to create test tenant') + } + + return tenant +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 641900d859..3c8c80cad1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: '@adonisjs/fold': specifier: ^8.2.0 version: 8.2.0 + '@apollo/client': + specifier: ^3.11.8 + version: 3.11.8(@types/react@18.2.73)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@apollo/server': specifier: ^4.11.2 version: 4.11.2(graphql@16.8.1) @@ -472,9 +475,6 @@ importers: specifier: ^9.0.1 version: 9.0.1 devDependencies: - '@apollo/client': - specifier: ^3.11.8 - version: 3.11.8(@types/react@18.2.73)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@graphql-codegen/cli': specifier: 5.0.2 version: 5.0.2(@babel/core@7.26.0)(@types/node@18.19.64)(graphql@16.8.1) diff --git a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml index e0cf08b12b..e205c052cc 100644 --- a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml @@ -31,6 +31,8 @@ services: PRIVATE_KEY_FILE: /workspace/private-key.pem AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-test-auth:3107 AUTH_SERVER_GRANT_URL: http://cloud-nine-wallet-test-auth:3106 + AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-test-auth:3003/graphql' + AUTH_ADMIN_API_SECRET: 'test-secret' ILP_ADDRESS: test.cloud-nine-wallet-test ILP_CONNECTOR_URL: http://cloud-nine-wallet-test-backend:3102 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= diff --git a/test/integration/testenv/happy-life-bank/docker-compose.yml b/test/integration/testenv/happy-life-bank/docker-compose.yml index 9cba1bc0c7..6fcb2e6f39 100644 --- a/test/integration/testenv/happy-life-bank/docker-compose.yml +++ b/test/integration/testenv/happy-life-bank/docker-compose.yml @@ -26,6 +26,8 @@ services: DATABASE_URL: postgresql://happy_life_bank_test_backend:happy_life_bank_test_backend@shared-database/happy_life_bank_test_backend AUTH_SERVER_GRANT_URL: http://happy-life-bank-test-auth:4106 AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-test-auth:4107 + AUTH_ADMIN_API_URL: 'http://happy-life-bank-test-auth:4003/graphql' + AUTH_ADMIN_API_SECRET: 'test-secret' # matches pfry key id KEY_ID: keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 PRIVATE_KEY_FILE: /workspace/private-key.pem From 10bc368bbc2a2137538d388ca35464b401afd0ef Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Wed, 11 Dec 2024 10:54:52 +0100 Subject: [PATCH 05/47] feat(auth, backend): seed operator tenant (#3156) * feat(auth): migration to seed operator tenant * feat(backend): migration to seed operator tenant * chore(localenv): add env vars for operator tenant * test(backend): set operator env variables in jest config * test(auth): set operator env variables in jest config * test(auth, backend): load env vars into jest environment script * feat(auth,backend): update migrations with error messages * test(integration): adding operator tenant vars * chore(backend, localenv): replace OPERATOR_TENANT_SECRET with existing API_SECRET --- localenv/cloud-nine-wallet/docker-compose.yml | 2 + localenv/happy-life-bank/docker-compose.yml | 2 + packages/auth/jest.env.js | 1 + packages/auth/jest.setup.js | 1 + .../20241205153036_seed_operator_tenant.js | 47 +++++++++++++++++++ packages/auth/src/config/app.ts | 3 +- packages/backend/jest.config.js | 16 +------ packages/backend/jest.env.js | 16 +++++++ packages/backend/jest.setup.ts | 1 + .../20241205153035_seed_operator_tenant.js | 30 ++++++++++++ packages/backend/src/config/app.ts | 5 +- .../cloud-nine-wallet/docker-compose.yml | 3 ++ .../happy-life-bank/docker-compose.yml | 3 ++ 13 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 packages/auth/migrations/20241205153036_seed_operator_tenant.js create mode 100644 packages/backend/jest.env.js create mode 100644 packages/backend/migrations/20241205153035_seed_operator_tenant.js diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index a493be4b71..6f22a11e27 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -78,6 +78,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 @@ -117,6 +118,7 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 depends_on: - shared-database - shared-redis diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 93475143f1..15d41cce4a 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -71,6 +71,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: @@ -106,6 +107,7 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d depends_on: - cloud-nine-auth happy-life-admin: 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/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/src/config/app.ts b/packages/auth/src/config/app.ts index 549d71cb9a..f3372e16f6 100644 --- a/packages/auth/src/config/app.ts +++ b/packages/auth/src/config/app.ts @@ -78,7 +78,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/backend/jest.config.js b/packages/backend/jest.config.js index c0245a4d3b..cdb688ff95 100644 --- a/packages/backend/jest.config.js +++ b/packages/backend/jest.config.js @@ -4,26 +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.AUTH_ADMIN_API_URL = 'http://127.0.0.1:3003/graphql' -process.env.AUTH_ADMIN_API_SECRET = 'test-secret' -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..5509b4f583 --- /dev/null +++ b/packages/backend/jest.env.js @@ -0,0 +1,16 @@ +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 +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/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/src/config/app.ts b/packages/backend/src/config/app.ts index 2f70ab2164..ec70dbb608 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -162,7 +162,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), adminApiSignatureTtl: envInt('ADMIN_API_SIGNATURE_TTL_SECONDS', 30), @@ -195,7 +195,8 @@ 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') } function parseRedisTlsConfig( diff --git a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml index e205c052cc..02bdc3d156 100644 --- a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml @@ -40,6 +40,8 @@ services: EXCHANGE_RATES_URL: http://host.docker.internal:8888/rates REDIS_URL: redis://shared-redis:6379/0 USE_TIGERBEETLE: false + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= volumes: - ../private-key.pem:/workspace/private-key.pem depends_on: @@ -72,6 +74,7 @@ services: IDENTITY_SERVER_URL: http://localhost:3030/mock-idp/ IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 + OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 depends_on: - shared-database - shared-redis \ No newline at end of file diff --git a/test/integration/testenv/happy-life-bank/docker-compose.yml b/test/integration/testenv/happy-life-bank/docker-compose.yml index 6fcb2e6f39..40fbc64263 100644 --- a/test/integration/testenv/happy-life-bank/docker-compose.yml +++ b/test/integration/testenv/happy-life-bank/docker-compose.yml @@ -38,6 +38,8 @@ services: EXCHANGE_RATES_URL: http://host.docker.internal:8889/rates REDIS_URL: redis://shared-redis:6379/2 USE_TIGERBEETLE: false + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= volumes: - ../private-key.pem:/workspace/private-key.pem depends_on: @@ -73,5 +75,6 @@ services: IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 AUTH_CHOICE_PORT: 4109 + OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d depends_on: - cloud-nine-wallet-test-auth \ No newline at end of file From 1c43cddd2feb308d1a8040a68a5eba71e0a0f535 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Mon, 16 Dec 2024 12:45:11 +0100 Subject: [PATCH 06/47] feat(integration): sign Admin API requests during integration tests (#3177) * fix(backend): await signature verification * test(integration): add signatures to apollo client requests * test(backend): sign GraphQL requests in test environment * Revert "test(backend): sign GraphQL requests in test environment" This reverts commit 0a128d1b0ebad8281a7e6401da3b25c628c5a033. * chore(backend): remove sig verification in test files --- packages/backend/src/app.ts | 4 +- pnpm-lock.yaml | 7 ++- test/integration/lib/apollo-client.ts | 50 +++++++++++++++++-- test/integration/lib/config.ts | 13 ++++- test/integration/lib/mock-ase.ts | 6 ++- test/integration/package.json | 2 + .../testenv/cloud-nine-wallet/.env | 2 + .../cloud-nine-wallet/docker-compose.yml | 1 + test/integration/testenv/happy-life-bank/.env | 2 + .../happy-life-bank/docker-compose.yml | 1 + 10 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 3bbe8d2662..fe7b31f3c6 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -383,9 +383,9 @@ export class App { } ) - if (this.config.adminApiSecret) { + if (this.config.env !== 'test') { koa.use(async (ctx, next: Koa.Next): Promise => { - if (!verifyApiSignature(ctx, this.config)) { + if (!(await verifyApiSignature(ctx, this.config))) { ctx.throw(401, 'Unauthorized') } return next() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80beef9929..b0a46fd0a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -779,9 +779,15 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + graphql: + specifier: ^16.8.1 + version: 16.8.1 hostile: specifier: ^1.4.0 version: 1.4.0 + json-canonicalize: + specifier: ^1.0.6 + version: 1.0.6 koa: specifier: ^2.15.3 version: 2.15.3 @@ -13193,7 +13199,6 @@ packages: /json-canonicalize@1.0.6: resolution: {integrity: sha512-kP2iYpOS5SZHYhIaR1t9oG80d4uTY3jPoaBj+nimy3njtJk8+sRsVatN8pyJRDRtk9Su3+6XqA2U8k0dByJBUQ==} - dev: false /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} diff --git a/test/integration/lib/apollo-client.ts b/test/integration/lib/apollo-client.ts index 6ef4647bd8..5cb5c06734 100644 --- a/test/integration/lib/apollo-client.ts +++ b/test/integration/lib/apollo-client.ts @@ -1,11 +1,55 @@ import type { NormalizedCacheObject } from '@apollo/client' -import { ApolloClient, InMemoryCache } from '@apollo/client' +import { + ApolloClient, + ApolloLink, + createHttpLink, + InMemoryCache +} from '@apollo/client' +import { createHmac } from 'crypto' +import { print } from 'graphql/language/printer' +import { canonicalize } from 'json-canonicalize' +import { setContext } from '@apollo/client/link/context' -export function createApolloClient( +interface CreateApolloClientArgs { graphqlUrl: string + signatureSecret: string + signatureVersion: string +} + +function createAuthLink(args: CreateApolloClientArgs) { + return setContext((request, { headers }) => { + const timestamp = Math.round(new Date().getTime() / 1000) + const version = args.signatureVersion + + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) + } + + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac('sha256', args.signatureSecret) + hmac.update(payload) + const digest = hmac.digest('hex') + return { + headers: { + ...headers, + signature: `t=${timestamp}, v${version}=${digest}` + } + } + }) +} + +export function createApolloClient( + args: CreateApolloClientArgs ): ApolloClient { + const httpLink = createHttpLink({ + uri: args.graphqlUrl + }) + return new ApolloClient({ - uri: graphqlUrl, + link: ApolloLink.from([createAuthLink(args), httpLink]), cache: new InMemoryCache(), defaultOptions: { query: { diff --git a/test/integration/lib/config.ts b/test/integration/lib/config.ts index 86aab0b22d..723a36f87d 100644 --- a/test/integration/lib/config.ts +++ b/test/integration/lib/config.ts @@ -11,6 +11,8 @@ export type TestConfig = Config & { interactionServer: string walletAddressUrl: string keyId: string + signatureSecret: string + signatureVersion: string } type EnvConfig = { @@ -22,7 +24,10 @@ type EnvConfig = { GRAPHQL_URL: string KEY_ID: string IDP_SECRET: string + SIGNATURE_SECRET: string + SIGNATURE_VERSION: string } + const REQUIRED_KEYS: (keyof EnvConfig)[] = [ 'OPEN_PAYMENTS_URL', 'AUTH_SERVER_DOMAIN', @@ -31,7 +36,9 @@ const REQUIRED_KEYS: (keyof EnvConfig)[] = [ 'WALLET_ADDRESS_URL', 'GRAPHQL_URL', 'KEY_ID', - 'IDP_SECRET' + 'IDP_SECRET', + 'SIGNATURE_SECRET', + 'SIGNATURE_VERSION' ] const loadEnv = (filePath: string): EnvConfig => { @@ -69,7 +76,9 @@ const createConfig = (name: string): TestConfig => { walletAddressUrl: env.WALLET_ADDRESS_URL, graphqlUrl: env.GRAPHQL_URL, keyId: env.KEY_ID, - idpSecret: env.IDP_SECRET + idpSecret: env.IDP_SECRET, + signatureSecret: env.SIGNATURE_SECRET, + signatureVersion: env.SIGNATURE_VERSION } } diff --git a/test/integration/lib/mock-ase.ts b/test/integration/lib/mock-ase.ts index 4642907b4f..4b9d7f81c9 100644 --- a/test/integration/lib/mock-ase.ts +++ b/test/integration/lib/mock-ase.ts @@ -30,7 +30,11 @@ export class MockASE { // Use static MockASE.create instead. private constructor(config: TestConfig) { this.config = config - this.apolloClient = createApolloClient(config.graphqlUrl) + this.apolloClient = createApolloClient({ + graphqlUrl: config.graphqlUrl, + signatureSecret: config.signatureSecret, + signatureVersion: config.signatureVersion + }) this.adminClient = new AdminClient(this.apolloClient) this.accounts = new AccountProvider() this.integrationServer = new IntegrationServer( diff --git a/test/integration/package.json b/test/integration/package.json index f5393c8a62..ec68b9ef1b 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -22,7 +22,9 @@ "@types/koa-bodyparser": "^4.3.12", "@types/node": "^20.14.15", "dotenv": "^16.4.5", + "graphql": "^16.8.1", "hostile": "^1.4.0", + "json-canonicalize": "^1.0.6", "koa": "^2.15.3", "mock-account-service-lib": "workspace:*", "yaml": "^2.6.0" diff --git a/test/integration/testenv/cloud-nine-wallet/.env b/test/integration/testenv/cloud-nine-wallet/.env index f2cb1e1bbc..bf7a0b7fd0 100644 --- a/test/integration/testenv/cloud-nine-wallet/.env +++ b/test/integration/testenv/cloud-nine-wallet/.env @@ -5,5 +5,7 @@ INTEGRATION_SERVER_PORT=8888 WALLET_ADDRESS_URL=https://cloud-nine-wallet-test-backend:3100/.well-known/pay GRAPHQL_URL=http://cloud-nine-wallet-test-backend:3101/graphql IDP_SECRET=2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= +SIGNATURE_VERSION=1 +SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= # matches pfry key id KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file diff --git a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml index 02bdc3d156..18630e78eb 100644 --- a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml @@ -42,6 +42,7 @@ services: USE_TIGERBEETLE: false OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + API_SIGNATURE_VERSION: 1 volumes: - ../private-key.pem:/workspace/private-key.pem depends_on: diff --git a/test/integration/testenv/happy-life-bank/.env b/test/integration/testenv/happy-life-bank/.env index d5da571ecc..9ab63a2ed7 100644 --- a/test/integration/testenv/happy-life-bank/.env +++ b/test/integration/testenv/happy-life-bank/.env @@ -5,5 +5,7 @@ INTEGRATION_SERVER_PORT=8889 WALLET_ADDRESS_URL=https://happy-life-bank-test-backend:4100/accounts/pfry GRAPHQL_URL=http://happy-life-bank-test-backend:4101/graphql IDP_SECRET=2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= +SIGNATURE_VERSION=1 +SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= # matches pfry key id KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file diff --git a/test/integration/testenv/happy-life-bank/docker-compose.yml b/test/integration/testenv/happy-life-bank/docker-compose.yml index 40fbc64263..a0a42586ba 100644 --- a/test/integration/testenv/happy-life-bank/docker-compose.yml +++ b/test/integration/testenv/happy-life-bank/docker-compose.yml @@ -40,6 +40,7 @@ services: USE_TIGERBEETLE: false OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= + API_SIGNATURE_VERSION: 1 volumes: - ../private-key.pem:/workspace/private-key.pem depends_on: From a8b7ca4d8057d79c500f8250c22214f24361409f Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 17 Dec 2024 07:23:18 -0800 Subject: [PATCH 07/47] feat(backend): tenant signature validation for admin api (#3164) * feat(auth): tenants table v1 * feat(backend): tenant service * feat: use soft delete * feat: add idp columns to tenant model * feat: pagination tests, push deletedAt to auth api call * feat: add cache * feat(backend): tenant signature validation for admin api * fix: rebase errors * fix: remove admin api secret check from app * fix: always expect tenant id in request * chore: remove some logs * feat: await signature verification, test improvements * fix: better util parameters * fix: add tenant info to apollo context * feat: fix integration tests * fix: make tenant required on extended apollo context --- packages/backend/src/app.ts | 23 ++- packages/backend/src/shared/utils.test.ts | 190 +++++++++++++++++- packages/backend/src/shared/utils.ts | 62 +++++- packages/backend/src/tenants/service.test.ts | 15 +- packages/backend/src/tests/tenant.ts | 3 +- test/integration/lib/apollo-client.ts | 4 +- test/integration/lib/config.ts | 8 +- test/integration/lib/mock-ase.ts | 3 +- .../testenv/cloud-nine-wallet/.env | 1 + test/integration/testenv/happy-life-bank/.env | 1 + 10 files changed, 294 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index fe7b31f3c6..03fbbcf1b6 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -85,7 +85,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 +100,11 @@ 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' export interface AppContextData { logger: Logger container: AppContainer @@ -214,6 +218,11 @@ type ContextType = T extends ( const WALLET_ADDRESS_PATH = '/:walletAddressPath+' +export interface TenantedApolloContext extends ApolloContext { + tenant: Tenant + isOperator: boolean +} + export interface AppServices { logger: Promise telemetry: Promise @@ -383,10 +392,17 @@ export class App { } ) + let tenantApiSignatureResult: TenantApiSignatureResult if (this.config.env !== 'test') { koa.use(async (ctx, next: Koa.Next): Promise => { - if (!(await verifyApiSignature(ctx, this.config))) { + 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() }) @@ -394,8 +410,9 @@ export class App { koa.use( koaMiddleware(this.apolloServer, { - context: async (): Promise => { + context: async (): Promise => { return { + ...tenantApiSignatureResult, container: this.container, logger: await this.container.use('logger') } diff --git a/packages/backend/src/shared/utils.test.ts b/packages/backend/src/shared/utils.test.ts index b786ef8498..409f194c4e 100644 --- a/packages/backend/src/shared/utils.test.ts +++ b/packages/backend/src/shared/utils.test.ts @@ -1,13 +1,25 @@ +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 +} 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 => { @@ -258,4 +270,178 @@ 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() + }) + }) }) diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index 3f34098523..abe2c1917d 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 @@ -126,7 +127,8 @@ function getSignatureParts(signature: string) { function verifyApiSignatureDigest( signature: string, request: AppContext['request'], - config: IAppConfig + adminApiSignatureVersion: number, + secret: string ): boolean { const { body } = request const { @@ -135,12 +137,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 +173,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'] + 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 +233,10 @@ 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 + ) } diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 84fad049ca..da6d3b7009 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -61,7 +61,7 @@ describe('Tenant Service', (): void => { await appContainer.shutdown() }) - describe('Tenant pangination', (): void => { + describe('Tenant pagination', (): void => { describe('getPage', (): void => { getPageTests({ createModel: () => createTenant(deps), @@ -101,6 +101,19 @@ describe('Tenant Service', (): void => { const tenant = await tenantService.get(dbTenant.id) expect(tenant).toBeUndefined() }) + + 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() + }) }) describe('create', (): void => { diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts index 4ac1488b84..f174a58f2f 100644 --- a/packages/backend/src/tests/tenant.ts +++ b/packages/backend/src/tests/tenant.ts @@ -1,3 +1,4 @@ +import nock from 'nock' import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { AppServices } from '../app' @@ -11,8 +12,6 @@ interface CreateOptions { idpSecret: string } -const nock = (global as unknown as { nock: typeof import('nock') }).nock - export async function createTenant( deps: IocContract, options?: CreateOptions diff --git a/test/integration/lib/apollo-client.ts b/test/integration/lib/apollo-client.ts index 5cb5c06734..928b6f9b14 100644 --- a/test/integration/lib/apollo-client.ts +++ b/test/integration/lib/apollo-client.ts @@ -14,6 +14,7 @@ interface CreateApolloClientArgs { graphqlUrl: string signatureSecret: string signatureVersion: string + operatorTenantId: string } function createAuthLink(args: CreateApolloClientArgs) { @@ -35,7 +36,8 @@ function createAuthLink(args: CreateApolloClientArgs) { return { headers: { ...headers, - signature: `t=${timestamp}, v${version}=${digest}` + signature: `t=${timestamp}, v${version}=${digest}`, + 'tenant-id': args.operatorTenantId } } }) diff --git a/test/integration/lib/config.ts b/test/integration/lib/config.ts index 723a36f87d..7c51a1ecef 100644 --- a/test/integration/lib/config.ts +++ b/test/integration/lib/config.ts @@ -13,6 +13,7 @@ export type TestConfig = Config & { keyId: string signatureSecret: string signatureVersion: string + operatorTenantId: string } type EnvConfig = { @@ -26,6 +27,7 @@ type EnvConfig = { IDP_SECRET: string SIGNATURE_SECRET: string SIGNATURE_VERSION: string + OPERATOR_TENANT_ID: string } const REQUIRED_KEYS: (keyof EnvConfig)[] = [ @@ -38,7 +40,8 @@ const REQUIRED_KEYS: (keyof EnvConfig)[] = [ 'KEY_ID', 'IDP_SECRET', 'SIGNATURE_SECRET', - 'SIGNATURE_VERSION' + 'SIGNATURE_VERSION', + 'OPERATOR_TENANT_ID' ] const loadEnv = (filePath: string): EnvConfig => { @@ -78,7 +81,8 @@ const createConfig = (name: string): TestConfig => { keyId: env.KEY_ID, idpSecret: env.IDP_SECRET, signatureSecret: env.SIGNATURE_SECRET, - signatureVersion: env.SIGNATURE_VERSION + signatureVersion: env.SIGNATURE_VERSION, + operatorTenantId: env.OPERATOR_TENANT_ID } } diff --git a/test/integration/lib/mock-ase.ts b/test/integration/lib/mock-ase.ts index 4b9d7f81c9..31a40a8e4c 100644 --- a/test/integration/lib/mock-ase.ts +++ b/test/integration/lib/mock-ase.ts @@ -33,7 +33,8 @@ export class MockASE { this.apolloClient = createApolloClient({ graphqlUrl: config.graphqlUrl, signatureSecret: config.signatureSecret, - signatureVersion: config.signatureVersion + signatureVersion: config.signatureVersion, + operatorTenantId: config.operatorTenantId }) this.adminClient = new AdminClient(this.apolloClient) this.accounts = new AccountProvider() diff --git a/test/integration/testenv/cloud-nine-wallet/.env b/test/integration/testenv/cloud-nine-wallet/.env index bf7a0b7fd0..23aa3996c0 100644 --- a/test/integration/testenv/cloud-nine-wallet/.env +++ b/test/integration/testenv/cloud-nine-wallet/.env @@ -7,5 +7,6 @@ GRAPHQL_URL=http://cloud-nine-wallet-test-backend:3101/graphql IDP_SECRET=2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= SIGNATURE_VERSION=1 SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= +OPERATOR_TENANT_ID=438fa74a-fa7d-4317-9ced-dde32ece1787 # matches pfry key id KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file diff --git a/test/integration/testenv/happy-life-bank/.env b/test/integration/testenv/happy-life-bank/.env index 9ab63a2ed7..4037251585 100644 --- a/test/integration/testenv/happy-life-bank/.env +++ b/test/integration/testenv/happy-life-bank/.env @@ -7,5 +7,6 @@ GRAPHQL_URL=http://happy-life-bank-test-backend:4101/graphql IDP_SECRET=2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= SIGNATURE_VERSION=1 SIGNATURE_SECRET=iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= +OPERATOR_TENANT_ID=cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d # matches pfry key id KEY_ID=keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 \ No newline at end of file From fd8283b8f71d14aa42927cf8e04c7bd02d6b6f8a Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Tue, 14 Jan 2025 13:39:17 -0500 Subject: [PATCH 08/47] feat: auth service-to-service api (#3148) * feat(auth): add service api with /healtz endpoint * feat(auth): tenant routes * feat(auth): service api error handling * chore(auth): rm old todo * fix(auth): how errors are set * fix(auth): improve tenant tests, cleanup tenant get response, * feat(backend): auth service api client * fix(auth): change status codes to 204 where no body * fix(backend): format * feat(auth): add required deletedAt to DELETE /tenant body * feat(backend): AUTH_SERVICE_API_URL env var * fix(backend): auth service client tests to mock codes correctly * feat(backend): add AuthServiceClient dep * feat(backend): use auth service client in tenant service * chore(auth): format * chore(auth): format * fix(integration,localenv): auth service api config * fix(backend,auth): update tenant api to support deletedAt * docs: update with env vars * fix(backend): dep container type * fix(localenv): docker compose config * fix(backend): add default header to api client --- localenv/cloud-nine-wallet/docker-compose.yml | 3 + localenv/happy-life-bank/docker-compose.yml | 3 + packages/auth/src/app.ts | 53 ++++ packages/auth/src/config/app.ts | 1 + packages/auth/src/index.ts | 14 ++ packages/auth/src/shared/utils.test.ts | 17 +- packages/auth/src/shared/utils.ts | 5 + packages/auth/src/tenant/routes.test.ts | 227 ++++++++++++++++++ packages/auth/src/tenant/routes.ts | 147 ++++++++++++ packages/auth/src/tenant/service.test.ts | 43 ++-- packages/auth/src/tenant/service.ts | 9 +- packages/auth/src/tests/app.ts | 1 + packages/backend/jest.env.js | 1 + packages/backend/src/app.ts | 2 + .../src/auth-service-client/client.test.ts | 123 ++++++++++ .../backend/src/auth-service-client/client.ts | 92 +++++++ packages/backend/src/config/app.ts | 1 + packages/backend/src/index.ts | 9 +- packages/backend/src/tenants/service.test.ts | 210 ++++++---------- packages/backend/src/tenants/service.ts | 64 +---- packages/backend/src/tests/tenant.ts | 10 +- .../docs/integration/prod/docker-compose.mdx | 3 + .../src/partials/auth-variables.mdx | 1 + .../src/partials/backend-variables.mdx | 1 + .../cloud-nine-wallet/docker-compose.yml | 3 + .../happy-life-bank/docker-compose.yml | 3 + 26 files changed, 835 insertions(+), 211 deletions(-) create mode 100644 packages/auth/src/tenant/routes.test.ts create mode 100644 packages/auth/src/tenant/routes.ts create mode 100644 packages/backend/src/auth-service-client/client.test.ts create mode 100644 packages/backend/src/auth-service-client/client.ts diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 6f22a11e27..5f1a6177bb 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -67,6 +67,7 @@ services: 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= @@ -108,6 +109,7 @@ services: - '3006:3006' - "9230:9229" - '3009:3009' + - '3011:3011' environment: NODE_ENV: ${NODE_ENV:-development} TRUST_PROXY: ${TRUST_PROXY} @@ -119,6 +121,7 @@ services: 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 diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 15d41cce4a..ba4acb74d5 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -60,6 +60,7 @@ services: 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= @@ -98,6 +99,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 @@ -108,6 +110,7 @@ services: 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: diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index dba8efebda..40895cc000 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -113,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!: { @@ -455,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 @@ -500,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 { @@ -530,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 f3372e16f6..ef4daeffc0 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), diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 356b321cf1..b997a29f6a 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -22,6 +22,7 @@ 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) @@ -163,6 +164,16 @@ export function initIocContainer( } ) + 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( @@ -315,6 +326,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/shared/utils.test.ts b/packages/auth/src/shared/utils.test.ts index 8a5b2630ee..5bfbab445a 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 => { @@ -145,4 +145,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 c3b6ea5e26..f3b9bcbd84 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/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..0ffe7c5940 --- /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 index fa553b7b18..d5b68ffc15 100644 --- a/packages/auth/src/tenant/service.test.ts +++ b/packages/auth/src/tenant/service.test.ts @@ -39,12 +39,14 @@ describe('Tenant Service', (): void => { const tenantData = createTenantData() const tenant = await tenantService.create(tenantData) - expect(tenant).toMatchObject({ + expect(tenant).toEqual({ id: tenantData.id, idpConsentUrl: tenantData.idpConsentUrl, - idpSecret: tenantData.idpSecret + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: undefined }) - expect(tenant.deletedAt).toBe(undefined) }) test('fails to create tenant with duplicate id', async (): Promise => { @@ -61,7 +63,14 @@ describe('Tenant Service', (): void => { const created = await tenantService.create(tenantData) const tenant = await tenantService.get(created.id) - expect(tenant).toMatchObject(tenantData) + 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 => { @@ -72,7 +81,7 @@ describe('Tenant Service', (): void => { test('returns undefined for soft deleted tenant', async (): Promise => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - await tenantService.delete(created.id) + await tenantService.delete(created.id, new Date()) const tenant = await tenantService.get(created.id) expect(tenant).toBeUndefined() @@ -90,9 +99,12 @@ describe('Tenant Service', (): void => { } const updated = await tenantService.update(created.id, updateData) - expect(updated).toMatchObject({ + expect(updated).toEqual({ id: created.id, - ...updateData + ...updateData, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null }) }) @@ -105,10 +117,13 @@ describe('Tenant Service', (): void => { } const updated = await tenantService.update(created.id, updateData) - expect(updated).toMatchObject({ + expect(updated).toEqual({ id: created.id, idpConsentUrl: updateData.idpConsentUrl, - idpSecret: created.idpSecret + idpSecret: created.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null }) }) @@ -122,7 +137,7 @@ describe('Tenant Service', (): void => { test('returns undefined for soft-deleted tenant', async (): Promise => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - await tenantService.delete(created.id) + await tenantService.delete(created.id, new Date()) const updated = await tenantService.update(created.id, { idpConsentUrl: faker.internet.url() @@ -136,7 +151,7 @@ describe('Tenant Service', (): void => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - const result = await tenantService.delete(created.id) + const result = await tenantService.delete(created.id, new Date()) expect(result).toBe(true) const tenant = await tenantService.get(created.id) @@ -150,7 +165,7 @@ describe('Tenant Service', (): void => { }) test('returns false for non-existent tenant', async (): Promise => { - const result = await tenantService.delete(faker.string.uuid()) + const result = await tenantService.delete(faker.string.uuid(), new Date()) expect(result).toBe(false) }) @@ -158,8 +173,8 @@ describe('Tenant Service', (): void => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - await tenantService.delete(created.id) - const secondDelete = await tenantService.delete(created.id) + 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 index d8d9f2a24c..d4f3cc336a 100644 --- a/packages/auth/src/tenant/service.ts +++ b/packages/auth/src/tenant/service.ts @@ -15,7 +15,7 @@ export interface TenantService { id: string, input: Partial> ): Promise - delete(id: string): Promise + delete(id: string, deletedAt: Date): Promise } interface ServiceDependencies extends BaseService { @@ -39,7 +39,7 @@ export async function createTenantService({ get: (id: string) => getTenant(deps, id), update: (id: string, input: Partial>) => updateTenant(deps, id, input), - delete: (id: string) => deleteTenant(deps, id) + delete: (id: string, deletedAt: Date) => deleteTenant(deps, id, deletedAt) } } @@ -72,10 +72,11 @@ async function updateTenant( async function deleteTenant( deps: ServiceDependencies, - id: string + id: string, + deletedAt: Date ): Promise { const deleted = await Tenant.query(deps.knex) - .patch({ deletedAt: new Date() }) + .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/backend/jest.env.js b/packages/backend/jest.env.js index 5509b4f583..4a8435dd72 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -6,6 +6,7 @@ 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 diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 03fbbcf1b6..db0e174012 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -105,6 +105,7 @@ import { getTenantFromApiSignature, TenantApiSignatureResult } from './shared/utils' +import { AuthServiceClient } from './auth-service-client/client' export interface AppContextData { logger: Logger container: AppContainer @@ -265,6 +266,7 @@ export interface AppServices { paymentMethodHandlerService: Promise ilpPaymentService: Promise localPaymentService: Promise + authServiceClient: AuthServiceClient } export type AppContainer = IocContract 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..da2e0a9a72 --- /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 ec70dbb608..0d51cc501f 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -129,6 +129,7 @@ export const Config = { 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 diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b167410756..c0be42bb28 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -74,6 +74,7 @@ import { 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' BigInt.prototype.toJSON = function () { return this.toString() @@ -220,12 +221,16 @@ export function initIocContainer( 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'), - apolloClient: await deps.use('apolloClient'), - tenantCache: await deps.use('tenantCache') + tenantCache: await deps.use('tenantCache'), + authServiceClient: deps.use('authServiceClient') }) }) diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index da6d3b7009..3a7234b0de 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -7,49 +7,28 @@ import { AppServices } from '../app' import { initIocContainer } from '..' import { createTestApp, TestContainer } from '../tests/app' import { TenantService } from './service' -import { Config, IAppConfig } from '../config/app' +import { Config } from '../config/app' import { truncateTables } from '../tests/tableManager' -import { ApolloClient, NormalizedCacheObject } from '@apollo/client' 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' - -const generateMutateGqlError = (path: string = 'createTenant') => ({ - errors: [ - { - message: 'invalid input syntax', - locations: [ - { - line: 1, - column: 1 - } - ], - path: [path], - extensions: { - code: 'INTERNAl_SERVER_ERROR' - } - } - ], - data: null -}) +import { AuthServiceClient } from '../auth-service-client/client' describe('Tenant Service', (): void => { let deps: IocContract let appContainer: TestContainer let tenantService: TenantService - let config: IAppConfig - let apolloClient: ApolloClient let knex: Knex + let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { deps = initIocContainer(Config) appContainer = await createTestApp(deps) tenantService = await deps.use('tenantService') - config = await deps.use('config') - apolloClient = await deps.use('apolloClient') knex = await deps.use('knex') + authServiceClient = await deps.use('authServiceClient') }) afterEach(async (): Promise => { @@ -126,28 +105,21 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') const tenant = await tenantService.create(createOptions) expect(tenant).toEqual(expect.objectContaining(createOptions)) - expect(apolloSpy).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( expect.objectContaining({ - variables: { - input: { - id: tenant.id, - idpSecret: createOptions.idpSecret, - idpConsentUrl: createOptions.idpConsentUrl - } - } + id: tenant.id, + idpSecret: createOptions.idpSecret, + idpConsentUrl: createOptions.idpConsentUrl }) ) - - scope.done() }) test('tenant creation rolls back if auth tenant create fails', async (): Promise => { @@ -159,11 +131,13 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, generateMutateGqlError('createTenant')) + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(() => { + throw new Error() + }) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + expect.assertions(3) let tenant try { tenant = await tenantService.create(createOptions) @@ -173,19 +147,14 @@ describe('Tenant Service', (): void => { const tenants = await Tenant.query() expect(tenants.length).toEqual(0) - expect(apolloSpy).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( expect.objectContaining({ - variables: { - input: { - id: expect.any(String), - idpConsentUrl: createOptions.idpConsentUrl, - idpSecret: createOptions.idpSecret - } - } + id: expect.any(String), + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret }) ) } - scope.done() }) }) @@ -199,10 +168,10 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) - .persist() + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(originalTenantInfo) const updatedTenantInfo = { @@ -214,22 +183,16 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret-two' } - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => undefined) const updatedTenant = await tenantService.update(updatedTenantInfo) expect(updatedTenant).toEqual(expect.objectContaining(updatedTenantInfo)) - expect(apolloSpy).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - input: { - id: tenant.id, - idpConsentUrl: updatedTenantInfo.idpConsentUrl, - idpSecret: updatedTenantInfo.idpSecret - } - } - }) - ) - scope.done() + expect(spy).toHaveBeenCalledWith(tenant.id, { + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + }) }) test('rolls back tenant if auth tenant update fails', async (): Promise => { @@ -241,9 +204,10 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(originalTenantInfo) const updatedTenantInfo = { id: tenant.id, @@ -254,13 +218,14 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret-two' } - nock.cleanAll() + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => { + throw new Error() + }) - nock(config.authAdminApiUrl) - .post('') - .reply(200, generateMutateGqlError('updateTenant')) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') let updatedTenant + expect.assertions(3) try { updatedTenant = await tenantService.update(updatedTenantInfo) } catch (err) { @@ -268,20 +233,14 @@ describe('Tenant Service', (): void => { const dbTenant = await Tenant.query().findById(tenant.id) assert.ok(dbTenant) expect(dbTenant).toEqual(expect.objectContaining(originalTenantInfo)) - expect(apolloSpy).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( + tenant.id, expect.objectContaining({ - variables: { - input: { - id: tenant.id, - idpConsentUrl: updatedTenantInfo.idpConsentUrl, - idpSecret: updatedTenantInfo.idpSecret - } - } + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret }) ) } - - nock.cleanAll() }) test('Cannot update deleted tenant', async (): Promise => { @@ -294,7 +253,7 @@ describe('Tenant Service', (): void => { deletedAt: new Date() }) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const spy = jest.spyOn(authServiceClient.tenant, 'update') try { await tenantService.update({ id: dbTenant.id, @@ -307,7 +266,7 @@ describe('Tenant Service', (): void => { assert.ok(dbTenantAfterUpdate) expect(dbTenantAfterUpdate.apiSecret).toEqual(originalSecret) - expect(apolloSpy).toHaveBeenCalledTimes(0) + expect(spy).toHaveBeenCalledTimes(0) } }) }) @@ -322,28 +281,22 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) - .persist() + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) const tenant = await tenantService.create(createOptions) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => undefined) await tenantService.delete(tenant.id) const dbTenant = await Tenant.query().findById(tenant.id) - expect(dbTenant?.deletedAt?.getTime()).toBeLessThanOrEqual( + assert.ok(dbTenant?.deletedAt) + expect(dbTenant.deletedAt.getTime()).toBeLessThanOrEqual( new Date(Date.now()).getTime() ) - expect(apolloSpy).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - input: { id: tenant.id, deletedAt: dbTenant?.deletedAt } - } - }) - ) - - scope.done() + expect(spy).toHaveBeenCalledWith(tenant.id, dbTenant.deletedAt) }) test('Reverts deletion if auth tenant delete fails', async (): Promise => { @@ -355,17 +308,18 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) const tenant = await tenantService.create(createOptions) - nock.cleanAll() + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => { + throw new Error() + }) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') - const deleteScope = nock(config.authAdminApiUrl) - .post('') - .reply(200, generateMutateGqlError('deleteTenant')) + expect.assertions(3) try { await tenantService.delete(tenant.id) } catch (err) { @@ -373,28 +327,17 @@ describe('Tenant Service', (): void => { assert.ok(dbTenant) expect(dbTenant.id).toEqual(tenant.id) expect(dbTenant.deletedAt).toBeNull() - expect(apolloSpy).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - input: { - id: tenant.id, - deletedAt: expect.any(Date) - } - } - }) - ) + expect(spy).toHaveBeenCalledWith(tenant.id, expect.any(Date)) } - - deleteScope.done() }) }) describe('Tenant Service using cache', (): void => { let deps: IocContract let appContainer: TestContainer - let config: IAppConfig let tenantService: TenantService let tenantCache: CacheDataStore + let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { deps = initIocContainer({ @@ -402,9 +345,9 @@ describe('Tenant Service', (): void => { localCacheDuration: 5_000 // 5-second default. }) appContainer = await createTestApp(deps) - config = await deps.use('config') tenantService = await deps.use('tenantService') tenantCache = await deps.use('tenantCache') + authServiceClient = await deps.use('authServiceClient') }) afterEach(async (): Promise => { @@ -425,10 +368,9 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { tenant: { id: 1234 } } } }) - .persist() + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) const spyCacheSet = jest.spyOn(tenantCache, 'set') const tenant = await tenantService.create(createOptions) @@ -447,6 +389,9 @@ describe('Tenant Service', (): void => { 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' @@ -461,6 +406,9 @@ describe('Tenant Service', (): void => { 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() @@ -468,8 +416,6 @@ describe('Tenant Service', (): void => { // Ensure that cache was set for deletion expect(spyCacheDelete).toHaveBeenCalledTimes(1) expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) - - scope.done() }) }) }) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index d1973471eb..947881619f 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -1,10 +1,9 @@ import { Tenant } from './model' import { BaseService } from '../shared/baseService' -import { gql, NormalizedCacheObject } from '@apollo/client' -import { ApolloClient } from '@apollo/client' 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' export interface TenantService { get: (id: string) => Promise @@ -16,8 +15,8 @@ export interface TenantService { export interface ServiceDependencies extends BaseService { knex: TransactionOrKnex - apolloClient: ApolloClient tenantCache: CacheDataStore + authServiceClient: AuthServiceClient } export async function createTenantService( @@ -83,26 +82,12 @@ async function createTenant( idpConsentUrl }) - const mutation = gql` - mutation CreateAuthTenant($input: CreateTenantInput!) { - createTenant(input: $input) { - tenant { - id - } - } - } - ` - - const variables = { - input: { - id: tenant.id, - idpSecret, - idpConsentUrl - } - } + await deps.authServiceClient.tenant.create({ + id: tenant.id, + idpSecret, + idpConsentUrl + }) - // TODO: add type to this in https://github.com/interledger/rafiki/issues/3125 - await deps.apolloClient.mutate({ mutation, variables }) await trx.commit() await deps.tenantCache.set(tenant.id, tenant) @@ -143,26 +128,10 @@ async function updateTenant( .throwIfNotFound() if (idpConsentUrl || idpSecret) { - const mutation = gql` - mutation UpdateAuthTenant($input: UpdateTenantInput!) { - updateTenant(input: $input) { - tenant { - id - } - } - } - ` - - const variables = { - input: { - id, - idpConsentUrl, - idpSecret - } - } - - // TODO: add types to this in https://github.com/interledger/rafiki/issues/3125 - await deps.apolloClient.mutate({ mutation, variables }) + await deps.authServiceClient.tenant.update(id, { + idpConsentUrl, + idpSecret + }) } await trx.commit() @@ -186,16 +155,7 @@ async function deleteTenant( await Tenant.query(trx).patchAndFetchById(id, { deletedAt }) - const mutation = gql` - mutation DeleteAuthTenantMutation($input: DeleteTenantInput!) { - deleteTenant(input: $input) { - sucess - } - } - ` - const variables = { input: { id, deletedAt } } - // TODO: add types to this in https://github.com/interledger/rafiki/issues/3125 - await deps.apolloClient.mutate({ mutation, variables }) + await deps.authServiceClient.tenant.delete(id, deletedAt) await trx.commit() } catch (err) { await trx.rollback() diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts index f174a58f2f..579735b73d 100644 --- a/packages/backend/src/tests/tenant.ts +++ b/packages/backend/src/tests/tenant.ts @@ -1,4 +1,3 @@ -import nock from 'nock' import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { AppServices } from '../app' @@ -17,10 +16,10 @@ export async function createTenant( options?: CreateOptions ): Promise { const tenantService = await deps.use('tenantService') - const config = await deps.use('config') - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + const authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) const tenant = await tenantService.create( options || { email: faker.internet.email(), @@ -30,7 +29,6 @@ export async function createTenant( idpSecret: 'test-idp-secret' } ) - scope.done() if (!tenant) { throw Error('Failed to create test tenant') diff --git a/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx b/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx index 9f206994b4..6cda45440a 100644 --- a/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx +++ b/packages/documentation/src/content/docs/integration/prod/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 d4851b48e3..b970386202 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/test/integration/testenv/cloud-nine-wallet/docker-compose.yml b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml index 18630e78eb..d0a45c6bd9 100644 --- a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml @@ -33,6 +33,7 @@ services: AUTH_SERVER_GRANT_URL: http://cloud-nine-wallet-test-auth:3106 AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-test-auth:3003/graphql' AUTH_ADMIN_API_SECRET: 'test-secret' + AUTH_SERVICE_API_URL: 'http://cloud-nine-wallet-auth:3111/' ILP_ADDRESS: test.cloud-nine-wallet-test ILP_CONNECTOR_URL: http://cloud-nine-wallet-test-backend:3102 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= @@ -62,6 +63,7 @@ services: - '3106:3106' - '3107:3107' - '3109:3109' + - '3111:3111' environment: NODE_ENV: ${NODE_ENV:-development} AUTH_SERVER_URL: http://cloud-nine-wallet-test-auth:3106 @@ -71,6 +73,7 @@ services: INTERACTION_PORT: 3109 AUTH_PORT: 3106 ADMIN_PORT: 3103 + SERVICE_API_PORT: 3111 REDIS_URL: redis://shared-redis:6379/1 IDENTITY_SERVER_URL: http://localhost:3030/mock-idp/ IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= diff --git a/test/integration/testenv/happy-life-bank/docker-compose.yml b/test/integration/testenv/happy-life-bank/docker-compose.yml index a0a42586ba..951a769ad3 100644 --- a/test/integration/testenv/happy-life-bank/docker-compose.yml +++ b/test/integration/testenv/happy-life-bank/docker-compose.yml @@ -28,6 +28,7 @@ services: AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-test-auth:4107 AUTH_ADMIN_API_URL: 'http://happy-life-bank-test-auth:4003/graphql' AUTH_ADMIN_API_SECRET: 'test-secret' + AUTH_SERVICE_API_URL: 'http://happy-life-bank-test-auth:4111/' # matches pfry key id KEY_ID: keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 PRIVATE_KEY_FILE: /workspace/private-key.pem @@ -62,6 +63,7 @@ services: - '4106:4106' - '4107:4107' - '4109:4109' + - '4111:4111' environment: NODE_ENV: development AUTH_DATABASE_URL: postgresql://happy_life_bank_test_auth:happy_life_bank_test_auth@shared-database/happy_life_bank_test_auth @@ -70,6 +72,7 @@ services: INTERACTION_PORT: 4109 INTROSPECTION_PORT: 4107 ADMIN_PORT: 4103 + SERVICE_API_PORT: 4111 AUTH_PORT: 4106 REDIS_URL: redis://shared-redis:6379/3 IDENTITY_SERVER_URL: http://localhost:3030/mock-idp/ From 0b6fb1a8cca552c7b39e75f3177a95bba2593b20 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Wed, 15 Jan 2025 16:17:25 +0100 Subject: [PATCH 09/47] feat(backend): tenanted assets (#3206) * feat(backend): migration to backfill tenantId on assets * feat(backend): add tenantId to asset, use it in service * feat(backend): use tenantId in asset resolvers * test(backend): update tests to use asset tenantId where necessary * test(backend): truncate tenant table manually in tenant tests * test(backend): update failing accounting tests * test(backend): update tenant service test * test: fix accounting tests linting * test(backend): update accounting tests * feat(backend): use tenantId when fetching asset * test(backend): make tests work with separate middleware * test(backend): keep operator tenant when truncating tables * test(backend): skip tenant pagination tests for now * test(backend): seed operator tenant in truncateTable * test(backend): seed operator tenant after tenants service is done * test(backend): use separate schema for tenant tests * test(backend): pass operator tenant id in pagination tests * feat(backend): make tenantId required in asset pagination * test(backend): update tenant service tests * chore(backend): update config file * test: update truncateTables to take in dbSchema * feat(backend): make tenantId optional in asset pagination --- ...0241216160130_backfill_tenant_on_assets.js | 30 ++++ .../src/accounting/psql/balance.test.ts | 11 +- .../psql/ledger-account/index.test.ts | 5 +- .../psql/ledger-transfer/index.test.ts | 5 +- .../psql/ledger-transfer/model.test.ts | 10 +- .../src/accounting/psql/service.test.ts | 15 +- packages/backend/src/app.ts | 54 ++++-- packages/backend/src/asset/model.test.ts | 1 + packages/backend/src/asset/model.ts | 1 + packages/backend/src/asset/service.test.ts | 131 +++++++++++++-- packages/backend/src/asset/service.ts | 97 ++++++++--- packages/backend/src/config/app.ts | 3 +- .../src/graphql/resolvers/asset.test.ts | 12 +- .../backend/src/graphql/resolvers/asset.ts | 84 ++++++---- packages/backend/src/index.ts | 4 + .../backend/src/shared/pagination.test.ts | 11 +- packages/backend/src/shared/utils.ts | 2 +- packages/backend/src/tenants/service.test.ts | 154 +++++++++--------- packages/backend/src/tests/app.ts | 3 +- packages/backend/src/tests/asset.ts | 7 +- packages/backend/src/tests/tableManager.ts | 19 ++- 21 files changed, 474 insertions(+), 185 deletions(-) create mode 100644 packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js diff --git a/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js new file mode 100644 index 0000000000..85afdf458e --- /dev/null +++ b/packages/backend/migrations/20241216160130_backfill_tenant_on_assets.js @@ -0,0 +1,30 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema + .alterTable('assets', (table) => { + table.uuid('tenantId').references('tenants.id').index() + }) + .then(() => { + return knex.raw( + `UPDATE "assets" SET "tenantId" = (SELECT id from "tenants" LIMIT 1)` + ) + }) + .then(() => { + return knex.schema.alterTable('assets', (table) => { + table.uuid('tenantId').notNullable().alter() + }) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.alterTable('assets', (table) => { + table.dropColumn('tenantId') + }) +} diff --git a/packages/backend/src/accounting/psql/balance.test.ts b/packages/backend/src/accounting/psql/balance.test.ts index 559bbb229c..027c6f1eff 100644 --- a/packages/backend/src/accounting/psql/balance.test.ts +++ b/packages/backend/src/accounting/psql/balance.test.ts @@ -4,7 +4,7 @@ import { createTestApp, TestContainer } from '../../tests/app' import { Config } from '../../config/app' import { initIocContainer } from '../../' import { Asset } from '../../asset/model' -import { randomAsset } from '../../tests/asset' +import { createAsset } from '../../tests/asset' import { truncateTables } from '../../tests/tableManager' import { LedgerAccount } from './ledger-account/model' import { createLedgerAccount } from '../../tests/ledgerAccount' @@ -12,15 +12,18 @@ import { getAccountBalances } from './balance' import { ServiceDependencies } from './service' import { LedgerTransferState } from '../service' import { createLedgerTransfer } from '../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../app' describe('Balances', (): void => { + let deps: IocContract let serviceDeps: ServiceDependencies let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) serviceDeps = { logger: await deps.use('logger'), @@ -31,7 +34,7 @@ describe('Balances', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await createAsset(deps) }) afterEach(async (): Promise => { @@ -48,7 +51,7 @@ describe('Balances', (): void => { let peerAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await createAsset(deps) ;[account, peerAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex) diff --git a/packages/backend/src/accounting/psql/ledger-account/index.test.ts b/packages/backend/src/accounting/psql/ledger-account/index.test.ts index 4d5b0140ec..fdb9a898c2 100644 --- a/packages/backend/src/accounting/psql/ledger-account/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-account/index.test.ts @@ -32,7 +32,10 @@ describe('Ledger Account', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { diff --git a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts index 4a75ce60cc..3efb12f752 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/index.test.ts @@ -45,7 +45,10 @@ describe('Ledger Transfer', (): void => { let settlementAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[account, peerAccount, settlementAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount({ ledger: asset.ledger }, knex), diff --git a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts index 63dbcd8cef..aea03c66f5 100644 --- a/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts +++ b/packages/backend/src/accounting/psql/ledger-transfer/model.test.ts @@ -9,14 +9,17 @@ import { LedgerAccount, LedgerAccountType } from '../ledger-account/model' import { createLedgerAccount } from '../../../tests/ledgerAccount' import { LedgerTransferState } from '../../service' import { createLedgerTransfer } from '../../../tests/ledgerTransfer' +import { IocContract } from '@adonisjs/fold' +import { AppServices } from '../../../app' describe('Ledger Transfer Model', (): void => { + let deps: IocContract let appContainer: TestContainer let knex: Knex let asset: Asset beforeAll(async (): Promise => { - const deps = initIocContainer({ ...Config, useTigerBeetle: false }) + deps = initIocContainer({ ...Config, useTigerBeetle: false }) appContainer = await createTestApp(deps) knex = appContainer.knex }) @@ -25,7 +28,10 @@ describe('Ledger Transfer Model', (): void => { let debitAccount: LedgerAccount beforeEach(async (): Promise => { - asset = await Asset.query(knex).insertAndFetch(randomAsset()) + asset = await Asset.query(knex).insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) ;[creditAccount, debitAccount] = await Promise.all([ createLedgerAccount({ ledger: asset.ledger }, knex), createLedgerAccount( diff --git a/packages/backend/src/accounting/psql/service.test.ts b/packages/backend/src/accounting/psql/service.test.ts index 316cdf811e..e799068137 100644 --- a/packages/backend/src/accounting/psql/service.test.ts +++ b/packages/backend/src/accounting/psql/service.test.ts @@ -54,7 +54,10 @@ describe('Psql Accounting Service', (): void => { }) beforeEach(async (): Promise => { - asset = await Asset.query().insertAndFetch(randomAsset()) + asset = await Asset.query().insertAndFetch({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) }) afterEach(async (): Promise => { @@ -892,7 +895,10 @@ describe('Psql Accounting Service', (): void => { const timeout = 10 // 10 seconds beforeEach(async (): Promise => { - const sourceAsset = await assetService.create(randomAsset()) + const sourceAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(sourceAsset)) sourceAccount = await accountFactory.build({ @@ -902,7 +908,10 @@ describe('Psql Accounting Service', (): void => { const destinationAsset = sameAsset ? sourceAsset - : await assetService.create(randomAsset()) + : await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(destinationAsset)) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index db0e174012..38fb458ad0 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -105,7 +105,9 @@ import { getTenantFromApiSignature, TenantApiSignatureResult } from './shared/utils' +import { TenantService } from './tenants/service' import { AuthServiceClient } from './auth-service-client/client' + export interface AppContextData { logger: Logger container: AppContainer @@ -266,6 +268,7 @@ export interface AppServices { paymentMethodHandlerService: Promise ilpPaymentService: Promise localPaymentService: Promise + tenantService: Promise authServiceClient: AuthServiceClient } @@ -395,21 +398,52 @@ export class App { ) let tenantApiSignatureResult: TenantApiSignatureResult - if (this.config.env !== 'test') { - koa.use(async (ctx, next: Koa.Next): Promise => { - const result = await getTenantFromApiSignature(ctx, this.config) - if (!result) { - ctx.throw(401, 'Unauthorized') - } else { + const tenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + const result = await getTenantFromApiSignature(ctx, this.config) + if (!result) { + ctx.throw(401, 'Unauthorized') + } else { + tenantApiSignatureResult = { + tenant: result.tenant, + isOperator: result.isOperator ? true : false + } + } + return next() + } + + const testTenantSignatureMiddleware = async ( + ctx: AppContext, + next: Koa.Next + ): Promise => { + if (ctx.headers['tenant-id']) { + const tenantService = await ctx.container.use('tenantService') + const tenant = await tenantService.get( + ctx.headers['tenant-id'] as string + ) + + if (tenant) { tenantApiSignatureResult = { - tenant: result.tenant, - isOperator: result.isOperator ? true : false + tenant, + isOperator: tenant.apiSecret === this.config.adminApiSecret } + } else { + ctx.throw(401, 'Unauthorized') } - return next() - }) + } + return next() } + // For tests, we still need to get the tenant in the middleware, but + // we don't need to verify the signature nor prevent replay attacks + koa.use( + this.config.env !== 'test' + ? tenantSignatureMiddleware + : testTenantSignatureMiddleware + ) + koa.use( koaMiddleware(this.apolloServer, { context: async (): Promise => { diff --git a/packages/backend/src/asset/model.test.ts b/packages/backend/src/asset/model.test.ts index 461fb043e5..d9464d7844 100644 --- a/packages/backend/src/asset/model.test.ts +++ b/packages/backend/src/asset/model.test.ts @@ -38,6 +38,7 @@ describe('Models', (): void => { beforeEach(async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, liquidityThreshold: BigInt(100) } const assetOrError = await assetService.create(options) diff --git a/packages/backend/src/asset/model.ts b/packages/backend/src/asset/model.ts index 62237fcd20..1dda49c754 100644 --- a/packages/backend/src/asset/model.ts +++ b/packages/backend/src/asset/model.ts @@ -13,6 +13,7 @@ export class Asset extends BaseModel implements LiquidityAccount { // TigerBeetle account 2 byte ledger field representing account's asset public readonly ledger!: number + public readonly tenantId!: string public readonly withdrawalThreshold!: bigint | null diff --git a/packages/backend/src/asset/service.test.ts b/packages/backend/src/asset/service.test.ts index 6c05b221a2..093187fa49 100644 --- a/packages/backend/src/asset/service.test.ts +++ b/packages/backend/src/asset/service.test.ts @@ -57,6 +57,7 @@ describe('Asset Service', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -80,7 +81,10 @@ describe('Asset Service', (): void => { 'createLiquidityAndLinkedSettlementAccount' ) - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) expect(liquidityAndSettlementSpy).toHaveBeenCalledWith( @@ -100,6 +104,7 @@ describe('Asset Service', (): void => { test('Asset can be created with minimum account withdrawal amount', async (): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10) } const asset = await assetService.create(options) @@ -113,7 +118,7 @@ describe('Asset Service', (): void => { }) test('Cannot create duplicate asset', async (): Promise => { - const options = randomAsset() + const options = { ...randomAsset(), tenantId: Config.operatorTenantId } await expect(assetService.create(options)).resolves.toMatchObject(options) await expect(assetService.create(options)).resolves.toEqual( AssetError.DuplicateAsset @@ -123,7 +128,8 @@ describe('Asset Service', (): void => { test('Cannot create asset with scale > 255', async (): Promise => { const options = { code: 'ABC', - scale: 256 + scale: 256, + tenantId: Config.operatorTenantId } await expect(assetService.create(options)).rejects.toThrow( CheckViolationError @@ -133,7 +139,10 @@ describe('Asset Service', (): void => { describe('get', (): void => { test('Can get asset by id', async (): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) await expect(assetService.get(asset.id)).resolves.toEqual(asset) }) @@ -161,6 +170,7 @@ describe('Asset Service', (): void => { beforeEach(async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -186,6 +196,7 @@ describe('Asset Service', (): void => { }): Promise => { const asset = await assetService.update({ id: assetId, + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold }) @@ -198,10 +209,29 @@ describe('Asset Service', (): void => { } ) + test('Cannot update asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.update({ + id: asset.id, + tenantId: uuid(), + withdrawalThreshold: BigInt(10), + liquidityThreshold: null + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) + test('Cannot update unknown asset', async (): Promise => { await expect( assetService.update({ id: uuid(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: null }) @@ -213,7 +243,11 @@ describe('Asset Service', (): void => { getPageTests({ createModel: () => createAsset(deps), getPage: (pagination?: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder) + assetService.getPage({ + pagination, + sortOrder, + tenantId: Config.operatorTenantId + }) }) }) @@ -221,7 +255,10 @@ describe('Asset Service', (): void => { test('returns all assets', async (): Promise => { const assets: (Asset | AssetError)[] = [] for (let i = 0; i < 3; i++) { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assets.push(asset) } @@ -235,12 +272,16 @@ describe('Asset Service', (): void => { describe('delete', (): void => { test('Can delete asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -248,18 +289,26 @@ describe('Asset Service', (): void => { }) test('Can delete and restore asset', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id const { code, scale } = newAsset const deletedAsset = await assetService.delete({ id: newAssetId, + tenantId: newAsset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) - const restoredAsset = await assetService.create({ code, scale }) + const restoredAsset = await assetService.create({ + code, + scale, + tenantId: newAsset.tenantId + }) assert.ok(!isAssetError(restoredAsset)) expect(restoredAsset.id).toEqual(newAssetId) expect(restoredAsset.code).toEqual(code) @@ -268,7 +317,10 @@ describe('Asset Service', (): void => { }) test('Cannot delete in use asset (wallet)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id @@ -280,12 +332,19 @@ describe('Asset Service', (): void => { assert.ok(!isWalletAddressError(walletAddress)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) test('Cannot delete in use asset (peer)', async (): Promise => { - const newAsset = await assetService.create(randomAsset()) + const newAsset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(newAsset)) const newAssetId = newAsset.id @@ -310,9 +369,30 @@ describe('Asset Service', (): void => { assert.ok(!isPeerError(peer)) await expect( - assetService.delete({ id: newAssetId, deletedAt: new Date() }) + assetService.delete({ + id: newAssetId, + tenantId: newAsset.tenantId, + deletedAt: new Date() + }) ).resolves.toEqual(AssetError.CannotDeleteInUseAsset) }) + + test('Cannot delete asset with incorrect tenantId', async (): Promise => { + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) + + assert.ok(!isAssetError(asset)) + + await expect( + assetService.delete({ + id: asset.id, + tenantId: uuid(), + deletedAt: new Date() + }) + ).resolves.toEqual(AssetError.UnknownAsset) + }) }) }) @@ -352,6 +432,7 @@ describe('Asset Service using Cache', (): void => { async ({ withdrawalThreshold, liquidityThreshold }): Promise => { const options = { ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold } @@ -380,6 +461,7 @@ describe('Asset Service using Cache', (): void => { const spyCacheUpdateSet = jest.spyOn(assetCache, 'set') const assetUpdate = await assetService.update({ id: asset.id, + tenantId: asset.tenantId, withdrawalThreshold, liquidityThreshold }) @@ -400,6 +482,7 @@ describe('Asset Service using Cache', (): void => { // Delete the asset, and ensure it is not cached: const deletedAsset = await assetService.delete({ id: asset.id, + tenantId: asset.tenantId, deletedAt: new Date() }) assert.ok(!isAssetError(deletedAsset)) @@ -409,4 +492,26 @@ describe('Asset Service using Cache', (): void => { } ) }) + + test('cannot get asset from cache if incorrect tenantId', async (): Promise => { + const options = { + ...randomAsset(), + tenantId: Config.operatorTenantId + } + const spyCacheSet = jest.spyOn(assetCache, 'set') + + const asset = await assetService.create(options) + assert.ok(!isAssetError(asset)) + + expect(spyCacheSet).toHaveBeenCalledWith( + asset.id, + expect.objectContaining(options) + ) + + const spyCacheGet = jest.spyOn(assetCache, 'get') + await expect(assetService.get(asset.id, uuid())).resolves.toEqual(undefined) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(asset.id) + }) }) diff --git a/packages/backend/src/asset/service.ts b/packages/backend/src/asset/service.ts index 5dbe8b63c1..9d68d1f4f7 100644 --- a/packages/backend/src/asset/service.ts +++ b/packages/backend/src/asset/service.ts @@ -15,27 +15,43 @@ export interface AssetOptions { } export interface CreateOptions extends AssetOptions { + tenantId: string withdrawalThreshold?: bigint liquidityThreshold?: bigint } export interface UpdateOptions { id: string + tenantId: string withdrawalThreshold: bigint | null liquidityThreshold: bigint | null } + export interface DeleteOptions { id: string + tenantId: string deletedAt: Date } +interface GetByCodeAndScaleOptions { + code: string + scale: number + tenantId: string +} + +interface GetPageOptions { + pagination?: Pagination + sortOrder?: SortOrder + tenantId?: string +} + export interface AssetService { create(options: CreateOptions): Promise update(options: UpdateOptions): Promise delete(options: DeleteOptions): Promise - get(id: string): Promise - getByCodeAndScale(code: string, scale: number): Promise - getPage(pagination?: Pagination, sortOrder?: SortOrder): Promise + get(id: string, tenantId?: string): Promise + getByCodeAndScale(options: GetByCodeAndScaleOptions): Promise + getPage(options: GetPageOptions): Promise getAll(): Promise } @@ -65,18 +81,22 @@ export async function createAssetService({ create: (options) => createAsset(deps, options), update: (options) => updateAsset(deps, options), delete: (options) => deleteAsset(deps, options), - get: (id) => getAsset(deps, id), - getByCodeAndScale: (code, scale) => - getAssetByCodeAndScale(deps, code, scale), - getPage: (pagination?, sortOrder?) => - getAssetsPage(deps, pagination, sortOrder), + get: (id, tenantId) => getAsset(deps, id, tenantId), + getByCodeAndScale: (options) => getAssetByCodeAndScale(deps, options), + getPage: (options) => getAssetsPage(deps, options), getAll: () => getAll(deps) } } async function createAsset( deps: ServiceDependencies, - { code, scale, withdrawalThreshold, liquidityThreshold }: CreateOptions + { + code, + scale, + withdrawalThreshold, + liquidityThreshold, + tenantId + }: CreateOptions ): Promise { try { // check if exists but deleted | by code-scale @@ -84,6 +104,7 @@ async function createAsset( .whereNotNull('deletedAt') .where('code', code) .andWhere('scale', scale) + .andWhere('tenantId', tenantId) .first() if (deletedAsset) { @@ -105,6 +126,7 @@ async function createAsset( const asset = await Asset.query(trx).insertAndFetch({ code, scale, + tenantId, withdrawalThreshold, liquidityThreshold }) @@ -126,14 +148,18 @@ async function createAsset( async function updateAsset( deps: ServiceDependencies, - { id, withdrawalThreshold, liquidityThreshold }: UpdateOptions + { id, tenantId, withdrawalThreshold, liquidityThreshold }: UpdateOptions ): Promise { if (!deps.knex) { throw new Error('Knex undefined') } try { const asset = await Asset.query(deps.knex) - .patchAndFetchById(id, { withdrawalThreshold, liquidityThreshold }) + .where({ tenantId }) + .patchAndFetchById(id, { + withdrawalThreshold, + liquidityThreshold + }) .throwIfNotFound() await deps.assetCache.set(id, asset) @@ -149,12 +175,20 @@ async function updateAsset( // soft delete async function deleteAsset( deps: ServiceDependencies, - { id, deletedAt }: DeleteOptions + options: DeleteOptions ): Promise { + const { id, tenantId, deletedAt } = options if (!deps.knex) { throw new Error('Knex undefined') } + // Check the correct tenant is requesting delete operation + const existingAsset = await getAsset(deps, id, tenantId) + + if (!existingAsset) { + return AssetError.UnknownAsset + } + await deps.assetCache.delete(id) try { // return error in case there is a peer or wallet address using the asset @@ -182,12 +216,22 @@ async function deleteAsset( async function getAsset( deps: ServiceDependencies, - id: string + id: string, + tenantId?: string ): Promise { const inMem = await deps.assetCache.get(id) - if (inMem) return inMem + if (inMem) { + return tenantId && inMem.tenantId !== tenantId ? undefined : inMem + } + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + const asset = await query.findById(id) - const asset = await Asset.query(deps.knex).whereNull('deletedAt').findById(id) if (asset) await deps.assetCache.set(asset.id, asset) return asset @@ -195,24 +239,27 @@ async function getAsset( async function getAssetByCodeAndScale( deps: ServiceDependencies, - code: string, - scale: number + options: GetByCodeAndScaleOptions ): Promise { - return await Asset.query(deps.knex) - .where({ code: code, scale: scale }) - .first() + return await Asset.query(deps.knex).where(options).first() } async function getAssetsPage( deps: ServiceDependencies, - pagination?: Pagination, - sortOrder?: SortOrder + options: GetPageOptions ): Promise { - return await Asset.query(deps.knex) - .whereNull('deletedAt') - .getPage(pagination, sortOrder) + const { tenantId, pagination, sortOrder } = options + + const query = Asset.query(deps.knex).whereNull('deletedAt') + + if (tenantId) { + query.andWhere({ tenantId }) + } + + return await query.getPage(pagination, sortOrder) } +// This used in auto-peering, what to do? async function getAll(deps: ServiceDependencies): Promise { return await Asset.query(deps.knex).whereNull('deletedAt') } diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 0d51cc501f..8b972cfe99 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -197,7 +197,8 @@ export const Config = { 5 ), localCacheDuration: envInt('LOCAL_CACHE_DURATION_MS', 15_000), - operatorTenantId: envString('OPERATOR_TENANT_ID') + operatorTenantId: envString('OPERATOR_TENANT_ID'), + dbSchema: undefined as string | undefined } function parseRedisTlsConfig( diff --git a/packages/backend/src/graphql/resolvers/asset.test.ts b/packages/backend/src/graphql/resolvers/asset.test.ts index 7968de3f98..7768d29a12 100644 --- a/packages/backend/src/graphql/resolvers/asset.test.ts +++ b/packages/backend/src/graphql/resolvers/asset.test.ts @@ -132,7 +132,7 @@ describe('Asset Resolvers', (): void => { test('Returns error for duplicate asset', async (): Promise => { const input = randomAsset() - await assetService.create(input) + await assetService.create({ ...input, tenantId: Config.operatorTenantId }) expect.assertions(2) try { @@ -218,6 +218,7 @@ describe('Asset Resolvers', (): void => { test('Can get an asset', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -283,6 +284,7 @@ describe('Asset Resolvers', (): void => { test('Can get an asset by code and scale', async (): Promise => { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -349,7 +351,10 @@ describe('Asset Resolvers', (): void => { { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Sending }, { fixed: BigInt(100), basisPoints: 1000, type: FeeType.Receiving } ])('Can get an asset with fee of %p', async (fee): Promise => { - const asset = await assetService.create(randomAsset()) + const asset = await assetService.create({ + ...randomAsset(), + tenantId: Config.operatorTenantId + }) assert.ok(!isAssetError(asset)) let expectedFee = null @@ -469,6 +474,7 @@ describe('Asset Resolvers', (): void => { createModel: () => assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) as Promise, @@ -480,6 +486,7 @@ describe('Asset Resolvers', (): void => { for (let i = 0; i < 2; i++) { const asset = await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold: BigInt(10), liquidityThreshold: BigInt(100) }) @@ -620,6 +627,7 @@ describe('Asset Resolvers', (): void => { beforeEach(async (): Promise => { asset = (await assetService.create({ ...randomAsset(), + tenantId: Config.operatorTenantId, withdrawalThreshold, liquidityThreshold })) as AssetModel diff --git a/packages/backend/src/graphql/resolvers/asset.ts b/packages/backend/src/graphql/resolvers/asset.ts index 50638d721c..c6862aa9b8 100644 --- a/packages/backend/src/graphql/resolvers/asset.ts +++ b/packages/backend/src/graphql/resolvers/asset.ts @@ -7,7 +7,7 @@ import { } from '../generated/graphql' import { Asset } from '../../asset/model' import { errorToCode, errorToMessage, isAssetError } from '../../asset/errors' -import { ApolloContext } from '../../app' +import { TenantedApolloContext } from '../../app' import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { feeToGraphql } from './fee' @@ -15,37 +15,42 @@ import { Fee, FeeType } from '../../fee/model' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' -export const getAssets: QueryResolvers['assets'] = async ( - parent, - args, - ctx -): Promise => { - const assetService = await ctx.container.use('assetService') - const { sortOrder, ...pagination } = args - const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc - const assets = await assetService.getPage(pagination, order) - const pageInfo = await getPageInfo({ - getPage: (pagination: Pagination, sortOrder?: SortOrder) => - assetService.getPage(pagination, sortOrder), - page: assets, - sortOrder: order - }) - return { - pageInfo, - edges: assets.map((asset: Asset) => ({ - cursor: asset.id, - node: assetToGraphql(asset) - })) +export const getAssets: QueryResolvers['assets'] = + async (parent, args, ctx): Promise => { + const assetService = await ctx.container.use('assetService') + const { sortOrder, ...pagination } = args + const order = sortOrder === 'ASC' ? SortOrder.Asc : SortOrder.Desc + const assets = await assetService.getPage({ + pagination, + sortOrder: order, + tenantId: ctx.tenant.id + }) + const pageInfo = await getPageInfo({ + getPage: (pagination: Pagination, sortOrder?: SortOrder) => + assetService.getPage({ + pagination, + sortOrder, + tenantId: ctx.tenant.id + }), + page: assets, + sortOrder: order + }) + return { + pageInfo, + edges: assets.map((asset: Asset) => ({ + cursor: asset.id, + node: assetToGraphql(asset) + })) + } } -} -export const getAsset: QueryResolvers['asset'] = async ( +export const getAsset: QueryResolvers['asset'] = async ( parent, args, ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.get(args.id) + const asset = await assetService.get(args.id, ctx.tenant.id) if (!asset) { throw new GraphQLError('Asset not found', { extensions: { @@ -56,21 +61,28 @@ export const getAsset: QueryResolvers['asset'] = async ( return assetToGraphql(asset) } -export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = +export const getAssetByCodeAndScale: QueryResolvers['assetByCodeAndScale'] = async (parent, args, ctx): Promise => { const assetService = await ctx.container.use('assetService') - const asset = await assetService.getByCodeAndScale(args.code, args.scale) + const asset = await assetService.getByCodeAndScale({ + code: args.code, + scale: args.scale, + tenantId: ctx.tenant.id + }) return asset ? assetToGraphql(asset) : null } -export const createAsset: MutationResolvers['createAsset'] = +export const createAsset: MutationResolvers['createAsset'] = async ( parent, args, ctx ): Promise => { const assetService = await ctx.container.use('assetService') - const assetOrError = await assetService.create(args.input) + const assetOrError = await assetService.create({ + ...args.input, + tenantId: ctx.tenant.id + }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { extensions: { @@ -83,7 +95,7 @@ export const createAsset: MutationResolvers['createAsset'] = } } -export const updateAsset: MutationResolvers['updateAsset'] = +export const updateAsset: MutationResolvers['updateAsset'] = async ( parent, args, @@ -93,7 +105,8 @@ export const updateAsset: MutationResolvers['updateAsset'] = const assetOrError = await assetService.update({ id: args.input.id, withdrawalThreshold: args.input.withdrawalThreshold ?? null, - liquidityThreshold: args.input.liquidityThreshold ?? null + liquidityThreshold: args.input.liquidityThreshold ?? null, + tenantId: ctx.tenant.id }) if (isAssetError(assetOrError)) { throw new GraphQLError(errorToMessage[assetOrError], { @@ -107,7 +120,7 @@ export const updateAsset: MutationResolvers['updateAsset'] = } } -export const getAssetSendingFee: AssetResolvers['sendingFee'] = +export const getAssetSendingFee: AssetResolvers['sendingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -119,7 +132,7 @@ export const getAssetSendingFee: AssetResolvers['sendingFee'] = return feeToGraphql(fee) } -export const getAssetReceivingFee: AssetResolvers['receivingFee'] = +export const getAssetReceivingFee: AssetResolvers['receivingFee'] = async (parent, args, ctx): Promise => { if (!parent.id) return null @@ -131,7 +144,7 @@ export const getAssetReceivingFee: AssetResolvers['receivingFee'] return feeToGraphql(fee) } -export const getFees: AssetResolvers['fees'] = async ( +export const getFees: AssetResolvers['fees'] = async ( parent, args, ctx @@ -159,7 +172,7 @@ export const getFees: AssetResolvers['fees'] = async ( } } -export const deleteAsset: MutationResolvers['deleteAsset'] = +export const deleteAsset: MutationResolvers['deleteAsset'] = async ( _, args, @@ -168,6 +181,7 @@ export const deleteAsset: MutationResolvers['deleteAsset'] = const assetService = await ctx.container.use('assetService') const assetOrError = await assetService.delete({ id: args.input.id, + tenantId: ctx.tenant.id, deletedAt: new Date() }) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c0be42bb28..8ab7092dcd 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -107,6 +107,7 @@ export function initIocContainer( directory: './', tableName: 'knex_migrations' }, + searchPath: config.dbSchema, log: { warn(message) { logger.warn(message) @@ -128,6 +129,9 @@ export function initIocContainer( 'text', BigInt ) + if (config.dbSchema) { + await db.raw(`CREATE SCHEMA IF NOT EXISTS "${config.dbSchema}"`) + } return db }) container.singleton('redis', async (deps): Promise => { diff --git a/packages/backend/src/shared/pagination.test.ts b/packages/backend/src/shared/pagination.test.ts index 18be4c5541..bd60935d40 100644 --- a/packages/backend/src/shared/pagination.test.ts +++ b/packages/backend/src/shared/pagination.test.ts @@ -300,9 +300,16 @@ describe('Pagination', (): void => { if (pagination.last) pagination.before = assetIds[cursor] else pagination.after = assetIds[cursor] } - const page = await assetService.getPage(pagination) + const page = await assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }) const pageInfo = await getPageInfo({ - getPage: (pagination) => assetService.getPage(pagination), + getPage: (pagination) => + assetService.getPage({ + pagination, + tenantId: config.operatorTenantId + }), page }) expect(pageInfo).toEqual({ diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index abe2c1917d..7b328bae0b 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -197,7 +197,7 @@ export async function getTenantFromApiSignature( } const tenantService = await ctx.container.use('tenantService') - const tenantId = headers['tenant-id'] + const tenantId = headers['tenant-id'] as string const tenant = tenantId ? await tenantService.get(tenantId) : undefined if (!tenant) return undefined diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 3a7234b0de..06837c18fe 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -7,7 +7,7 @@ import { AppServices } from '../app' import { initIocContainer } from '..' import { createTestApp, TestContainer } from '../tests/app' import { TenantService } from './service' -import { Config } from '../config/app' +import { Config, IAppConfig } from '../config/app' import { truncateTables } from '../tests/tableManager' import { Tenant } from './model' import { getPageTests } from '../shared/baseModel.test' @@ -15,24 +15,31 @@ import { Pagination, SortOrder } from '../shared/baseModel' import { createTenant } from '../tests/tenant' import { CacheDataStore } from '../middleware/cache/data-stores' import { AuthServiceClient } from '../auth-service-client/client' +import { withConfigOverride } from '../tests/helpers' describe('Tenant Service', (): void => { let deps: IocContract + let config: IAppConfig let appContainer: TestContainer let tenantService: TenantService let knex: Knex + const dbSchema = 'tenant_service_test_schema' let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { - deps = initIocContainer(Config) + deps = initIocContainer({ + ...Config, + dbSchema + }) appContainer = await createTestApp(deps) tenantService = await deps.use('tenantService') knex = await deps.use('knex') + config = await deps.use('config') authServiceClient = await deps.use('authServiceClient') }) afterEach(async (): Promise => { - await truncateTables(appContainer.knex) + await truncateTables(knex, true, dbSchema) }) afterAll(async (): Promise => { @@ -333,90 +340,83 @@ describe('Tenant Service', (): void => { }) describe('Tenant Service using cache', (): void => { - let deps: IocContract - let appContainer: TestContainer - let tenantService: TenantService let tenantCache: CacheDataStore let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { - deps = initIocContainer({ - ...Config, - localCacheDuration: 5_000 // 5-second default. - }) - appContainer = await createTestApp(deps) - tenantService = await deps.use('tenantService') tenantCache = await deps.use('tenantCache') authServiceClient = await deps.use('authServiceClient') }) - afterEach(async (): Promise => { - await truncateTables(appContainer.knex) - }) - - afterAll(async (): Promise => { - await appContainer.shutdown() - }) - describe('create, update, and retrieve tenant using cache', (): void => { - test('Tenant can be created, updated, and fetched', async (): Promise => { - const createOptions = { - email: faker.internet.email(), - publicName: faker.company.name(), - apiSecret: 'test-api-secret', - idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' - } - - jest - .spyOn(authServiceClient.tenant, 'create') - .mockImplementation(async () => undefined) - - const spyCacheSet = jest.spyOn(tenantCache, 'set') - const tenant = await tenantService.create(createOptions) - expect(tenant).toMatchObject({ - ...createOptions, - id: tenant.id - }) - - // Ensure that the cache was set for create - expect(spyCacheSet).toHaveBeenCalledTimes(1) - - const spyCacheGet = jest.spyOn(tenantCache, 'get') - await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) - - expect(spyCacheGet).toHaveBeenCalledTimes(1) - expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) - - const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') - jest - .spyOn(authServiceClient.tenant, 'update') - .mockImplementation(async () => undefined) - const updatedTenant = await tenantService.update({ - id: tenant.id, - apiSecret: 'test-api-secret-2' - }) - - await expect(tenantService.get(tenant.id)).resolves.toEqual( - updatedTenant + test( + 'Tenant can be created, updated, and fetched', + withConfigOverride( + () => config, + { localCacheDuration: 5_000 }, + async (): Promise => { + const createOptions = { + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: 'test-api-secret', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) + + const spyCacheSet = jest.spyOn(tenantCache, 'set') + const tenant = await tenantService.create(createOptions) + expect(tenant).toMatchObject({ + ...createOptions, + id: tenant.id + }) + + // Ensure that the cache was set for create + expect(spyCacheSet).toHaveBeenCalledTimes(1) + + const spyCacheGet = jest.spyOn(tenantCache, 'get') + await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) + + const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) + const updatedTenant = await tenantService.update({ + id: tenant.id, + apiSecret: 'test-api-secret-2' + }) + + await expect(tenantService.get(tenant.id)).resolves.toEqual( + updatedTenant + ) + + // Ensure that cache was set for update + expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) + expect(spyCacheUpdateSet).toHaveBeenCalledWith( + tenant.id, + updatedTenant + ) + + const spyCacheDelete = jest.spyOn(tenantCache, 'delete') + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) + await tenantService.delete(tenant.id) + + await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() + + // Ensure that cache was set for deletion + expect(spyCacheDelete).toHaveBeenCalledTimes(1) + expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) + } ) - - // Ensure that cache was set for update - expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) - expect(spyCacheUpdateSet).toHaveBeenCalledWith(tenant.id, updatedTenant) - - const spyCacheDelete = jest.spyOn(tenantCache, 'delete') - jest - .spyOn(authServiceClient.tenant, 'delete') - .mockImplementation(async () => undefined) - await tenantService.delete(tenant.id) - - await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() - - // Ensure that cache was set for deletion - expect(spyCacheDelete).toHaveBeenCalledTimes(1) - expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) - }) + ) }) }) }) diff --git a/packages/backend/src/tests/app.ts b/packages/backend/src/tests/app.ts index cbe82b4704..3642de6c1a 100644 --- a/packages/backend/src/tests/app.ts +++ b/packages/backend/src/tests/app.ts @@ -79,7 +79,8 @@ export const createTestApp = async ( const authLink = setContext((_, { headers }) => { return { headers: { - ...headers + ...headers, + 'tenant-id': config.operatorTenantId } } }) diff --git a/packages/backend/src/tests/asset.ts b/packages/backend/src/tests/asset.ts index daab8992be..d77fd1655b 100644 --- a/packages/backend/src/tests/asset.ts +++ b/packages/backend/src/tests/asset.ts @@ -27,8 +27,13 @@ export async function createAsset( deps: IocContract, options?: AssetOptions ): Promise { + const config = await deps.use('config') const assetService = await deps.use('assetService') - const assetOrError = await assetService.create(options || randomAsset()) + const createOptions = options || randomAsset() + const assetOrError = await assetService.create({ + ...createOptions, + tenantId: config.operatorTenantId + }) if (isAssetError(assetOrError)) { throw assetOrError } diff --git a/packages/backend/src/tests/tableManager.ts b/packages/backend/src/tests/tableManager.ts index 26f07d5d2d..9467127684 100644 --- a/packages/backend/src/tests/tableManager.ts +++ b/packages/backend/src/tests/tableManager.ts @@ -10,21 +10,28 @@ export async function truncateTable( export async function truncateTables( knex: Knex, - ignoreTables = [ + truncateTenants = false, + dbSchema?: string +): Promise { + const ignoreTables = [ 'knex_migrations', 'knex_migrations_lock', 'knex_migrations_backend', - 'knex_migrations_backend_lock' + 'knex_migrations_backend_lock', + ...(truncateTenants ? [] : ['tenants']) // So we don't delete operator tenant ] -): Promise { - const tables = await getTables(knex, ignoreTables) + const tables = await getTables(knex, dbSchema, ignoreTables) const RAW = `TRUNCATE TABLE "${tables}" RESTART IDENTITY` await knex.raw(RAW) } -async function getTables(knex: Knex, ignoredTables: string[]): Promise { +async function getTables( + knex: Knex, + dbSchema: string = 'public', + ignoredTables: string[] +): Promise { const result = await knex.raw( - "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='public'" + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='${dbSchema}'` ) return result.rows .map((val: { tablename: string }) => { From 1bb2a9b5c566406cf5e0186f0060ccb10b7d711f Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:23:08 -0500 Subject: [PATCH 10/47] feat(fronted): tenanted admin api credentials (#3213) * feat(frontend): set api credentials on session * chore(frontend): more details in todo comment * refactor(frontend): move credentials form from modal to component on index * chore(frontend): mark dialog for removal - not removing yet because not sure if we might end up using it. could be useful if we want to make global redirect if this is not set. * feat(frontend): store api creds in server side session * feat(frontend): POC for adding tenantId from session to headers for all apollo requests Uses the assets and list asset query. This POC passes the request to the listAsset function. Which imports the apolloClient directly and passes the cookie from request headers in the context. To avoid having to set this on each query as we compose it, my intention is to create a new getApolloClient function and use that insteadof directly importing a single client. This enables us to form a link to handle setting the headers per request (as opposed to static links that are used across all requests as it is currently). * feat(frontend): form apollo client per request - enables authLink to get tenantId, apiSecret from cookie in request - wondered if this was a performance concern (maybe why we had single instance before?) but found several things indicating this is OK and even recommended: - https://github.com/apollographql/apollo-client/issues/9520#issuecomment-1067397043 - https://www.apollographql.com/blog/how-to-use-apollo-client-with-remix * fix(mock-ase): update seed script to pass tenant sig/id verifcation * feat(frontend): block api cred form submit on invalid uuid * feat(frontend): handle errors, WIP apollo client - see TODOs in apollo client in frontend. maybe need to remove some env vars and verify how no tenantid/secret are handled * feat(frontend): disable nav links * docs(localenv): update readme to not say kratos is required * chore(frontend): format * chore(frontend): rm unused component * chore(frontend): rm commented out code * chore(frontend): formatting * refactor(frontend): better error parsing * chore(frontend): rm todo * refactor(frontend): use session api for deletion, not manual * fix(frontend): display error based on message reverses previous commit to use apollo error. proved unreliable * fix(frontend): rm SIGNATURE_SECRET, SIGNATURE_VERSION env vars * feat(mock-ase): log operator/tenant details to streamline use of frontend * feat(frontend): dont show nav items if api creds required and not set * feat(frontend): move api credential set action to own endpoint - removes the action from the index. the intention is to expose the remix server port over docker and call this from the MASE to set the api credentials on start * feat(frontend): prefill api credential form * chore(frontend): format * feat(frontend): auto submit form if values passed in requires changing intent to be set as an input. submitting form bypasses the button so the action didnt have the intent and failed when auto submitting. * fix: reinstate sig version env var --- localenv/README.md | 2 +- localenv/cloud-nine-wallet/docker-compose.yml | 5 +- localenv/happy-life-bank/docker-compose.yml | 3 +- .../app/entry.server.tsx | 22 ++++ .../app/lib/apolloClient.ts | 3 +- .../app/components/ApiCredentialsForm.tsx | 113 ++++++++++++++++++ packages/frontend/app/components/Sidebar.tsx | 15 ++- packages/frontend/app/lib/api/asset.server.ts | 35 ++++-- .../frontend/app/lib/api/payments.server.ts | 27 ++++- packages/frontend/app/lib/api/peer.server.ts | 28 +++-- .../app/lib/api/wallet-address.server.ts | 28 ++++- .../frontend/app/lib/api/webhook.server.ts | 8 +- packages/frontend/app/lib/apollo.server.ts | 77 ++++++------ packages/frontend/app/lib/session.server.ts | 15 +++ packages/frontend/app/root.tsx | 37 +++++- packages/frontend/app/routes/_index.tsx | 52 +++++++- .../app/routes/api.set-credentials.ts | 58 +++++++++ .../assets.$assetId.deposit-liquidity.tsx | 4 +- .../frontend/app/routes/assets.$assetId.tsx | 8 +- .../assets.$assetId.withdraw-liquidity.tsx | 4 +- .../routes/assets.$assetId_.fee-history.tsx | 2 +- .../frontend/app/routes/assets._index.tsx | 2 +- .../frontend/app/routes/assets.create.tsx | 2 +- .../frontend/app/routes/payments._index.tsx | 2 +- .../payments.incoming.$incomingPaymentId.tsx | 2 +- ....$incomingPaymentId.withdraw-liquidity.tsx | 2 +- ...g.$outgoingPaymentId.deposit-liquidity.tsx | 2 +- .../payments.outgoing.$outgoingPaymentId.tsx | 2 +- ....$outgoingPaymentId.withdraw-liquidity.tsx | 2 +- .../peers.$peerId.deposit-liquidity.tsx | 4 +- .../frontend/app/routes/peers.$peerId.tsx | 10 +- .../peers.$peerId.withdraw-liquidity.tsx | 4 +- packages/frontend/app/routes/peers._index.tsx | 2 +- packages/frontend/app/routes/peers.create.tsx | 4 +- .../wallet-addresses.$walletAddressId.tsx | 4 +- ...es.$walletAddressId.withdraw-liquidity.tsx | 2 +- .../app/routes/wallet-addresses._index.tsx | 2 +- .../app/routes/wallet-addresses.create.tsx | 4 +- .../frontend/app/routes/webhook-events.tsx | 2 +- 39 files changed, 479 insertions(+), 121 deletions(-) create mode 100644 packages/frontend/app/components/ApiCredentialsForm.tsx create mode 100644 packages/frontend/app/lib/session.server.ts create mode 100644 packages/frontend/app/routes/api.set-credentials.ts diff --git a/localenv/README.md b/localenv/README.md index 8646981d21..15c2f258eb 100644 --- a/localenv/README.md +++ b/localenv/README.md @@ -121,7 +121,7 @@ The secondary Happy Life Bank docker compose file (`./happy-life-bank/docker-com data stores created by the primary Rafiki instance so it can't be run by itself. The `pnpm localenv:compose up` command starts both the primary instance and the secondary. -See the `frontend` [README](../packages/frontend/README.md#ory-kratos) for more information regarding the Ory Kratos identity and user management system required for Admin UI. +See the `frontend` [README](../packages/frontend/README.md#ory-kratos) for more information regarding the Ory Kratos identity and user management system for the Admin UI. #### Autopeering diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index d1883d5aa7..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 @@ -166,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 467a1dec4c..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 @@ -136,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 d71fcb7d6d..ce935e0a8f 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/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..8555bae1ab 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 = [ @@ -38,9 +39,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 +90,7 @@ export const Sidebar: FC = ({ logoutUrl, authEnabled }) => {