diff --git a/docs/spec.yaml b/docs/spec.yaml index 7561439867..f47bd64adc 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -1335,6 +1335,12 @@ paths: properties: tags: $ref: '#/components/schemas/Tags' + webhook_url: + type: string + description: > + Override the environment's webhook URLs for this connection. + When set, this connection's webhooks are sent only to this URL. + Pass an empty string to clear the override. end_user: allOf: - $ref: '#/components/schemas/EndUser' diff --git a/packages/server/lib/controllers/connection/connectionId/patchConnection.integration.test.ts b/packages/server/lib/controllers/connection/connectionId/patchConnection.integration.test.ts index 9db0cf38e7..6878f32f6e 100644 --- a/packages/server/lib/controllers/connection/connectionId/patchConnection.integration.test.ts +++ b/packages/server/lib/controllers/connection/connectionId/patchConnection.integration.test.ts @@ -255,4 +255,69 @@ describe(`PATCH ${endpoint}`, () => { expect(updatedConn?.tags).toStrictEqual({}); }); }); + + describe('webhook_url', () => { + it('should set webhook_url override', async () => { + const { env, apiKey } = await seeders.seedAccountEnvAndUser(); + await seeders.createConfigSeed(env, 'github', 'github'); + const conn = await seeders.createConnectionSeed({ env, provider: 'github' }); + + const res = await api.fetch(endpoint, { + method: 'PATCH', + token: apiKey.secret, + params: { connectionId: conn.connection_id }, + query: { provider_config_key: 'github' }, + body: { webhook_url: 'https://example.com/webhooks-from-nango' } + }); + + isSuccess(res.json); + expect(res.json).toStrictEqual({ success: true }); + + const updatedConn = await db.knex.select('*').from('_nango_connections').where({ id: conn.id }).first(); + expect(updatedConn?.connection_config).toMatchObject({ webhook_url: 'https://example.com/webhooks-from-nango' }); + }); + + it('should clear webhook_url override with empty string', async () => { + const { env, apiKey } = await seeders.seedAccountEnvAndUser(); + await seeders.createConfigSeed(env, 'github', 'github'); + const conn = await seeders.createConnectionSeed({ + env, + provider: 'github', + connectionConfig: { webhook_url: 'https://example.com/webhooks-from-nango' } + }); + + const res = await api.fetch(endpoint, { + method: 'PATCH', + token: apiKey.secret, + params: { connectionId: conn.connection_id }, + query: { provider_config_key: 'github' }, + body: { webhook_url: '' } + }); + + isSuccess(res.json); + + const updatedConn = await db.knex.select('*').from('_nango_connections').where({ id: conn.id }).first(); + expect(updatedConn?.connection_config).not.toHaveProperty('webhook_url'); + }); + + it('should reject invalid webhook_url', async () => { + const { env, apiKey } = await seeders.seedAccountEnvAndUser(); + await seeders.createConfigSeed(env, 'github', 'github'); + const conn = await seeders.createConnectionSeed({ env, provider: 'github' }); + + const res = await api.fetch(endpoint, { + method: 'PATCH', + token: apiKey.secret, + params: { connectionId: conn.connection_id }, + query: { provider_config_key: 'github' }, + body: { webhook_url: 'not-a-url' } + }); + + isError(res.json); + expect(res.json).toMatchObject({ + error: { code: 'invalid_body' } + }); + expect(res.res.status).toBe(400); + }); + }); }); diff --git a/packages/server/lib/controllers/connection/connectionId/patchConnection.ts b/packages/server/lib/controllers/connection/connectionId/patchConnection.ts index f33d00d924..14250220d3 100644 --- a/packages/server/lib/controllers/connection/connectionId/patchConnection.ts +++ b/packages/server/lib/controllers/connection/connectionId/patchConnection.ts @@ -1,19 +1,13 @@ import * as z from 'zod'; -import db from '@nangohq/database'; -import { buildTagsFromEndUser, configService, connectionService, EndUserMapper, linkConnection, updateConnectionTags, upsertEndUser } from '@nangohq/shared'; import { zodErrorToHTTP } from '@nangohq/utils'; -import { connectionIdSchema, connectionTagsSchema, endUserSchema, providerConfigKeySchema } from '../../../helpers/validation.js'; +import { connectionIdSchema, providerConfigKeySchema } from '../../../helpers/validation.js'; import { asyncWrapper } from '../../../utils/asyncWrapper.js'; +import { handlePatchConnection, patchConnectionBodySchema } from '../../shared/connections/patchConnection.js'; import type { PatchPublicConnection } from '@nangohq/types'; -const schemaBody = z.strictObject({ - end_user: endUserSchema.optional(), - tags: connectionTagsSchema.optional() -}); - const queryStringValidation = z.strictObject({ provider_config_key: providerConfigKeySchema }); @@ -29,7 +23,7 @@ export const patchPublicConnection = asyncWrapper(async ( return; } - const valBody = schemaBody.safeParse(req.body); + const valBody = patchConnectionBodySchema.safeParse(req.body); if (!valBody.success) { res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); return; @@ -42,53 +36,15 @@ export const patchPublicConnection = asyncWrapper(async ( } const { environment, account } = res.locals; - const queryParams: PatchPublicConnection['Querystring'] = queryParamValues.data; const params: PatchPublicConnection['Params'] = paramValue.data; - const body: PatchPublicConnection['Body'] = valBody.data; - - const integration = await configService.getProviderConfig(queryParams.provider_config_key, environment.id); - if (!integration) { - res.status(400).send({ error: { code: 'unknown_provider_config', message: 'Provider does not exists' } }); - return; - } - - const connectionRes = await connectionService.getConnection(params.connectionId, queryParams.provider_config_key, environment.id); - if (connectionRes.error || !connectionRes.response) { - res.status(404).send({ error: { code: 'not_found', message: 'Failed to find connection' } }); - return; - } - - const connection = connectionRes.response; - - // Generate tags from end_user (similar to postSessions.ts and postReconnect.ts) - const endUserTags = body.end_user ? buildTagsFromEndUser(body.end_user, null) : {}; - const mergedTags = { ...endUserTags, ...body.tags }; - - if (body.end_user) { - await db.knex.transaction(async (trx) => { - const endUserRes = await upsertEndUser(trx, { account, environment, connection, endUser: EndUserMapper.apiToEndUser(body.end_user!) }); - if (endUserRes.isErr()) { - res.status(500).send({ error: { code: 'server_error', message: 'Failed to update end user' } }); - return; - } - - if (!connection.end_user_id) { - await linkConnection(trx, { endUserId: endUserRes.value.id, connection }); - } - }); - } - - if (body.end_user || body.tags !== undefined) { - const tagsRes = await updateConnectionTags(db.knex, { - connection, - tags: mergedTags - }); - if (tagsRes.isErr()) { - res.status(400).send({ error: { code: 'invalid_body', message: tagsRes.error.message } }); - return; - } - } - res.status(200).send({ success: true }); + await handlePatchConnection({ + res, + account, + environment, + connectionId: params.connectionId, + providerConfigKey: queryParams.provider_config_key, + body: valBody.data + }); }); diff --git a/packages/server/lib/controllers/shared/connections/patchConnection.ts b/packages/server/lib/controllers/shared/connections/patchConnection.ts new file mode 100644 index 0000000000..7a4ae872d9 --- /dev/null +++ b/packages/server/lib/controllers/shared/connections/patchConnection.ts @@ -0,0 +1,95 @@ +import * as z from 'zod'; + +import db from '@nangohq/database'; +import { buildTagsFromEndUser, configService, connectionService, EndUserMapper, linkConnection, updateConnectionTags, upsertEndUser } from '@nangohq/shared'; +import { Err, Ok } from '@nangohq/utils'; + +import { connectionTagsSchema, endUserSchema, webhookUrlSchema } from '../../../helpers/validation.js'; + +import type { RequestLocals } from '../../../utils/express.js'; +import type { DBEnvironment, DBTeam, EndUserInput, PatchPublicConnection, Tags } from '@nangohq/types'; +import type { Response } from 'express'; + +export const patchConnectionBodySchema = z.strictObject({ + end_user: endUserSchema.optional(), + tags: connectionTagsSchema.optional(), + webhook_url: webhookUrlSchema +}); + +export async function handlePatchConnection({ + res, + account, + environment, + connectionId, + providerConfigKey, + body +}: { + res: Response>; + account: DBTeam; + environment: DBEnvironment; + connectionId: string; + providerConfigKey: string; + body: { + end_user?: EndUserInput | undefined; + tags?: Tags | undefined; + webhook_url?: string | undefined; + }; +}): Promise { + const integration = await configService.getProviderConfig(providerConfigKey, environment.id); + if (!integration) { + res.status(400).send({ error: { code: 'unknown_provider_config', message: 'Provider does not exists' } }); + return; + } + + const connectionRes = await connectionService.getConnection(connectionId, providerConfigKey, environment.id); + if (connectionRes.error || !connectionRes.response) { + res.status(404).send({ error: { code: 'not_found', message: 'Failed to find connection' } }); + return; + } + + const connection = connectionRes.response; + + // Generate tags from end_user (similar to postSessions.ts and postReconnect.ts) + const endUserTags = body.end_user ? buildTagsFromEndUser(body.end_user, null) : {}; + const mergedTags = { ...endUserTags, ...body.tags }; + + if (body.end_user) { + const endUserTxRes = await db.knex.transaction(async (trx) => { + const endUserRes = await upsertEndUser(trx, { account, environment, connection, endUser: EndUserMapper.apiToEndUser(body.end_user!) }); + if (endUserRes.isErr()) { + return Err('Failed to update end user'); + } + + if (!connection.end_user_id) { + await linkConnection(trx, { endUserId: endUserRes.value.id, connection }); + } + + return Ok(undefined); + }); + if (endUserTxRes.isErr()) { + res.status(500).send({ error: { code: 'server_error', message: endUserTxRes.error.message } }); + return; + } + } + + if (body.end_user || body.tags !== undefined) { + const tagsRes = await updateConnectionTags(db.knex, { + connection, + tags: mergedTags + }); + if (tagsRes.isErr()) { + res.status(400).send({ error: { code: 'invalid_body', message: tagsRes.error.message } }); + return; + } + } + + if (typeof body.webhook_url === 'string') { + if (body.webhook_url === '') { + await connectionService.unsetConnectionConfigAttributes(connection, ['webhook_url']); + } else { + await connectionService.updateConnectionConfig(connection, { webhook_url: body.webhook_url }); + } + } + + res.status(200).send({ success: true }); +} diff --git a/packages/server/lib/controllers/v1/connections/connectionId/patchConnection.integration.test.ts b/packages/server/lib/controllers/v1/connections/connectionId/patchConnection.integration.test.ts new file mode 100644 index 0000000000..6febc9a2bc --- /dev/null +++ b/packages/server/lib/controllers/v1/connections/connectionId/patchConnection.integration.test.ts @@ -0,0 +1,64 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import db from '@nangohq/database'; +import { seeders } from '@nangohq/shared'; + +import { isSuccess, runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../../utils/tests.js'; + +import type { DBConnection } from '@nangohq/types'; + +const route = '/api/v1/connections/:connectionId'; +let api: Awaited>; + +describe(`PATCH ${route}`, () => { + beforeAll(async () => { + api = await runServer(); + }); + afterAll(() => { + api.server.close(); + }); + + it('should be protected', async () => { + const res = await api.fetch(route, { + method: 'PATCH', + params: { connectionId: 'test' }, + query: { env: 'dev', provider_config_key: 'github' }, + body: {} + }); + + shouldBeProtected(res); + }); + + it('should require query env', async () => { + const { apiKey } = await seeders.seedAccountEnvAndUser(); + const res = await api.fetch(route, { + method: 'PATCH', + token: apiKey.secret, + params: { connectionId: 'test' }, + query: { provider_config_key: 'github' } as any, + body: {} + }); + + shouldRequireQueryEnv(res); + }); + + it('should update webhook_url', async () => { + const { env, apiKey } = await seeders.seedAccountEnvAndUser(); + await seeders.createConfigSeed(env, 'github', 'github'); + const conn = await seeders.createConnectionSeed({ env, provider: 'github' }); + + const res = await api.fetch(route, { + method: 'PATCH', + token: apiKey.secret, + params: { connectionId: conn.connection_id }, + query: { env: env.name, provider_config_key: 'github' }, + body: { webhook_url: 'https://example.com/webhooks-from-nango' } + }); + + isSuccess(res.json); + expect(res.json).toStrictEqual({ success: true }); + + const updatedConn = await db.knex.select('*').from('_nango_connections').where({ id: conn.id }).first(); + expect(updatedConn?.connection_config).toMatchObject({ webhook_url: 'https://example.com/webhooks-from-nango' }); + }); +}); diff --git a/packages/server/lib/controllers/v1/connections/connectionId/patchConnection.ts b/packages/server/lib/controllers/v1/connections/connectionId/patchConnection.ts new file mode 100644 index 0000000000..2d36b6245e --- /dev/null +++ b/packages/server/lib/controllers/v1/connections/connectionId/patchConnection.ts @@ -0,0 +1,51 @@ +import * as z from 'zod'; + +import { zodErrorToHTTP } from '@nangohq/utils'; + +import { connectionIdSchema, envSchema, providerConfigKeySchema } from '../../../../helpers/validation.js'; +import { asyncWrapper } from '../../../../utils/asyncWrapper.js'; +import { handlePatchConnection, patchConnectionBodySchema } from '../../../shared/connections/patchConnection.js'; + +import type { PatchConnection } from '@nangohq/types'; + +const queryStringValidation = z.strictObject({ + provider_config_key: providerConfigKeySchema, + env: envSchema +}); + +const paramValidation = z.strictObject({ + connectionId: connectionIdSchema +}); + +export const patchConnection = asyncWrapper(async (req, res) => { + const queryParamValues = queryStringValidation.safeParse(req.query); + if (!queryParamValues.success) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(queryParamValues.error) } }); + return; + } + + const valBody = patchConnectionBodySchema.safeParse(req.body); + if (!valBody.success) { + res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } }); + return; + } + + const paramValue = paramValidation.safeParse(req.params); + if (!paramValue.success) { + res.status(400).send({ error: { code: 'invalid_uri_params', errors: zodErrorToHTTP(paramValue.error) } }); + return; + } + + const { environment, account } = res.locals; + const queryParams: PatchConnection['Querystring'] = queryParamValues.data; + const params: PatchConnection['Params'] = paramValue.data; + + await handlePatchConnection({ + res, + account, + environment, + connectionId: params.connectionId, + providerConfigKey: queryParams.provider_config_key, + body: valBody.data + }); +}); diff --git a/packages/server/lib/helpers/validation.ts b/packages/server/lib/helpers/validation.ts index 3679388833..edaa0980ed 100644 --- a/packages/server/lib/helpers/validation.ts +++ b/packages/server/lib/helpers/validation.ts @@ -39,7 +39,7 @@ export const webhookUrlSchema = z ); // Connection params come from untrusted clients. `webhook_url` is intentionally NOT accepted here: it is a -// privileged routing directive set only by the backend via the connect session (see postSessions). Any +// privileged routing directive set only by trusted actors (connect session, public API, dashboard). Any // client-supplied `webhook_url` is stripped in getConnectionConfig. export const connectionConfigParamsSchema = z.looseObject({}).optional(); diff --git a/packages/server/lib/routes.private.ts b/packages/server/lib/routes.private.ts index 89d45006ea..387a80a9c6 100644 --- a/packages/server/lib/routes.private.ts +++ b/packages/server/lib/routes.private.ts @@ -35,6 +35,7 @@ import { postImpersonate } from './controllers/v1/admin/impersonate/postImperson import { postInternalConnectSessions } from './controllers/v1/connect/sessions/postConnectSessions.js'; import { deleteConnection } from './controllers/v1/connections/connectionId/deleteConnection.js'; import { getConnection as getConnectionWeb } from './controllers/v1/connections/connectionId/getConnection.js'; +import { patchConnection } from './controllers/v1/connections/connectionId/patchConnection.js'; import { getConnectionRefresh } from './controllers/v1/connections/connectionId/postRefresh.js'; import { getConnectionRecordModels } from './controllers/v1/connections/connectionId/records/getModels.js'; import { getConnectionRecords } from './controllers/v1/connections/connectionId/records/getRecords.js'; @@ -272,6 +273,7 @@ web.route('/connections/:connectionId/records/models').get( ); web.route('/connections/:connectionId/records').get(webAuth, can({ action: 'read', resource: 'connection', scopedBy: envScope }), getConnectionRecords); web.route('/connections/:connectionId/refresh').post(webAuth, can({ action: 'update', resource: 'connection', scopedBy: envScope }), getConnectionRefresh); +web.route('/connections/:connectionId').patch(webAuth, can({ action: 'update', resource: 'connection', scopedBy: envScope }), patchConnection); web.route('/connections/:connectionId').delete(webAuth, can({ action: 'delete', resource: 'connection', scopedBy: envScope }), deleteConnection); web.route('/connections/admin/:connectionId').delete( webAuth, diff --git a/packages/shared/lib/utils/utils.ts b/packages/shared/lib/utils/utils.ts index cf035efcdc..09e7e34489 100644 --- a/packages/shared/lib/utils/utils.ts +++ b/packages/shared/lib/utils/utils.ts @@ -513,7 +513,7 @@ export function interpolateIfNeeded(str: string, replacers: Record) // Connection config keys a client is not allowed to supply: they are privileged and only set by the backend. const CLIENT_FORBIDDEN_CONNECTION_CONFIG_KEYS = new Set([ - // `webhook_url` routes a connection's webhooks, so it is only honored from the backend-set connect session. + // `webhook_url` routes a connection's webhooks, so it is only honored from trusted actors (connect session, public API, dashboard). 'webhook_url' ]); diff --git a/packages/shared/lib/utils/utils.unit.test.ts b/packages/shared/lib/utils/utils.unit.test.ts index 9f8ad5992b..5675c70032 100644 --- a/packages/shared/lib/utils/utils.unit.test.ts +++ b/packages/shared/lib/utils/utils.unit.test.ts @@ -26,8 +26,8 @@ describe('getConnectionConfig', () => { expect(utils.getConnectionConfig({ subdomain: 'acme', count: 5, flag: true })).toEqual({ subdomain: 'acme' }); }); - // webhook_url is a privileged routing directive: it must only be settable by the backend via the - // connect session, never by an untrusted client passing it as a connection param. Stripping it here + // webhook_url is a privileged routing directive: it must only be settable by trusted actors (connect session, public API, dashboard), + // never by an untrusted client passing it as a connection param. Stripping it here // closes every client-param entry point (auth endpoints + OAuth) in one place. it('strips webhook_url so an untrusted client cannot route webhooks', () => { expect(utils.getConnectionConfig({ subdomain: 'acme', webhook_url: 'https://attacker.example.com/hook' })).toEqual({ subdomain: 'acme' }); diff --git a/packages/types/lib/api.endpoints.ts b/packages/types/lib/api.endpoints.ts index c0076d72d6..2477b13eaa 100644 --- a/packages/types/lib/api.endpoints.ts +++ b/packages/types/lib/api.endpoints.ts @@ -43,6 +43,7 @@ import type { GetConnectionsCount, GetPublicConnection, GetPublicConnections, + PatchConnection, PatchPublicConnection, PostConnectionRefresh, PostPublicConnection @@ -233,6 +234,7 @@ export type PrivateApiEndpoints = | PatchFlowFrequency | PutUpgradePreBuiltFlow | PostConnectionRefresh + | PatchConnection | PostManagedEmailVerification | PostManagedSignup | PostPreBuiltDeploy diff --git a/packages/types/lib/connection/api/get.ts b/packages/types/lib/connection/api/get.ts index bd219b3867..73c4e90b4e 100644 --- a/packages/types/lib/connection/api/get.ts +++ b/packages/types/lib/connection/api/get.ts @@ -169,9 +169,29 @@ export type PatchPublicConnection = Endpoint<{ Body: { end_user?: EndUserInput | undefined; tags?: Tags | undefined; + webhook_url?: string | undefined; }; Success: { success: boolean }; - Error: ApiError<'unknown_provider_config'>; + Error: ApiError<'unknown_provider_config' | 'not_found' | 'server_error' | 'invalid_body'>; +}>; + +export type PatchConnection = Endpoint<{ + Method: 'PATCH'; + Path: '/api/v1/connections/:connectionId'; + Params: { + connectionId: string; + }; + Querystring: { + provider_config_key: string; + env: string; + }; + Body: { + end_user?: EndUserInput | undefined; + tags?: Tags | undefined; + webhook_url?: string | undefined; + }; + Success: { success: boolean }; + Error: ApiError<'unknown_provider_config' | 'not_found' | 'server_error' | 'invalid_body'>; }>; export type PostConnectionRefresh = Endpoint<{