From f5b185558279fa678bab2ff76424562aed3bff45 Mon Sep 17 00:00:00 2001 From: OthmanImam Date: Mon, 23 Feb 2026 17:11:33 +0100 Subject: [PATCH] feat(security): Enforce multi-tenant data isolation for vaults and webhooks. Require org_id/admin_address match for all sensitive queries. Add integration tests for cross-tenant access denial. --- .../src/graphql/__tests__/resolvers.test.ts | 23 +++++++++++ backend/src/graphql/middleware/auth.ts | 14 +++++++ .../src/graphql/resolvers/vaultResolver.ts | 39 +++++++------------ backend/src/services/indexingService.js | 25 +++++++----- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/backend/src/graphql/__tests__/resolvers.test.ts b/backend/src/graphql/__tests__/resolvers.test.ts index c67ba13b..0c6f623b 100644 --- a/backend/src/graphql/__tests__/resolvers.test.ts +++ b/backend/src/graphql/__tests__/resolvers.test.ts @@ -43,6 +43,29 @@ describe('GraphQL Resolvers', () => { }); describe('Vault Resolver', () => { + it('should deny access when admin does not belong to org', async () => { + // Simulate Organization A's admin trying to access Organization B's vault + const mockOrgA = { id: 'orgA', admin_address: '0xadminA' }; + const mockOrgB = { id: 'orgB', admin_address: '0xadminB' }; + // Mock isAdminOfOrg to return false + jest.mock('../middleware/auth', () => ({ + isAdminOfOrg: jest.fn().mockResolvedValue(false) + })); + await expect(vaultResolver.Query.vault(null, { address: '0xvaultB', orgId: mockOrgB.id, adminAddress: mockOrgA.admin_address })) + .rejects.toThrow('Access denied: admin does not belong to organization.'); + }); + + it('should allow access when admin belongs to org', async () => { + const mockOrgA = { id: 'orgA', admin_address: '0xadminA' }; + const mockVault = { id: '1', address: '0xvaultA', org_id: 'orgA', beneficiaries: [], subSchedules: [] }; + // Mock isAdminOfOrg to return true + jest.mock('../middleware/auth', () => ({ + isAdminOfOrg: jest.fn().mockResolvedValue(true) + })); + (models.Vault.findOne as jest.Mock).mockResolvedValue(mockVault); + const result = await vaultResolver.Query.vault(null, { address: '0xvaultA', orgId: mockOrgA.id, adminAddress: mockOrgA.admin_address }); + expect(result).toEqual(mockVault); + }); describe('Query.vault', () => { it('should fetch a vault by address', async () => { const mockVault = { diff --git a/backend/src/graphql/middleware/auth.ts b/backend/src/graphql/middleware/auth.ts index 6f61b46a..7821bf4a 100644 --- a/backend/src/graphql/middleware/auth.ts +++ b/backend/src/graphql/middleware/auth.ts @@ -1,3 +1,17 @@ +// Utility to check if admin_address belongs to org_id +export const isAdminOfOrg = async (adminAddress: string, orgId: string) => { + if (!adminAddress || !orgId) return false; + try { + const { models } = require('../../models'); + const org = await models.Organization.findOne({ + where: { id: orgId, admin_address: adminAddress } + }); + return !!org; + } catch (err) { + console.error('Error in isAdminOfOrg:', err); + return false; + } +}; import { GraphQLResolveInfo } from 'graphql'; export interface Context { diff --git a/backend/src/graphql/resolvers/vaultResolver.ts b/backend/src/graphql/resolvers/vaultResolver.ts index 5ea2e85f..92887b21 100644 --- a/backend/src/graphql/resolvers/vaultResolver.ts +++ b/backend/src/graphql/resolvers/vaultResolver.ts @@ -4,10 +4,16 @@ const tvlService = require('../../services/tvlService'); export const vaultResolver = { Query: { - vault: async (_: any, { address }: { address: string }) => { + vault: async (_: any, { address, orgId, adminAddress }: { address: string, orgId: string, adminAddress: string }) => { try { + // Enforce org/admin check + const { isAdminOfOrg } = require('../middleware/auth'); + const isAdmin = await isAdminOfOrg(adminAddress, orgId); + if (!isAdmin) { + throw new Error('Access denied: admin does not belong to organization.'); + } const vault = await models.Vault.findOne({ - where: { address }, + where: { address, org_id: orgId }, include: [ { model: models.Beneficiary, @@ -26,24 +32,17 @@ export const vaultResolver = { } }, - vaults: async (_: any, { ownerAddress, first = 50, after }: { ownerAddress?: string, first?: number, after?: string }) => { + vaults: async (_: any, { orgId, adminAddress, first = 50, after }: { orgId: string, adminAddress: string, first?: number, after?: string }) => { try { - // Check cache if ownerAddress is provided (user-specific query) - if (ownerAddress) { - const cacheKey = cacheService.getUserVaultsKey(ownerAddress); - const cachedVaults = await cacheService.get(cacheKey); - - if (cachedVaults) { - console.log(`Cache hit for user vaults: ${ownerAddress}`); - return cachedVaults; - } + // Enforce org/admin check + const { isAdminOfOrg } = require('../middleware/auth'); + const isAdmin = await isAdminOfOrg(adminAddress, orgId); + if (!isAdmin) { + throw new Error('Access denied: admin does not belong to organization.'); } - - const whereClause = ownerAddress ? { owner_address: ownerAddress } : {}; const offset = after ? parseInt(after) : 0; - const vaults = await models.Vault.findAll({ - where: whereClause, + where: { org_id: orgId }, include: [ { model: models.Beneficiary, @@ -58,14 +57,6 @@ export const vaultResolver = { offset, order: [['created_at', 'DESC']] }); - - // Cache the result if ownerAddress is provided - if (ownerAddress) { - const cacheKey = cacheService.getUserVaultsKey(ownerAddress); - await cacheService.set(cacheKey, vaults); - console.log(`Cached user vaults for: ${ownerAddress}`); - } - return vaults; } catch (error) { console.error('Error fetching vaults:', error); diff --git a/backend/src/services/indexingService.js b/backend/src/services/indexingService.js index 096bd413..2801cd69 100644 --- a/backend/src/services/indexingService.js +++ b/backend/src/services/indexingService.js @@ -56,19 +56,24 @@ class IndexingService { // Emit internal claim event for WebSocket gateway claimEventEmitter.emit('claim', claim.toJSON()); - // Fire webhook POST for DAOs + // Fire webhook POST for DAOs, but only if admin_address matches organization_id const { OrganizationWebhook } = require('../models'); + const { isAdminOfOrg } = require('../graphql/middleware/auth'); const axios = require('axios'); - // Find webhooks for the organization (if vault has organization_id) - if (claim.organization_id) { - const webhooks = await OrganizationWebhook.findAll({ where: { organization_id: claim.organization_id } }); - for (const webhook of webhooks) { - try { - await axios.post(webhook.webhook_url, claim.toJSON()); - console.log(`Webhook fired: ${webhook.webhook_url}`); - } catch (err) { - console.error(`Webhook failed: ${webhook.webhook_url}`, err); + if (claim.organization_id && claim.admin_address) { + const isAdmin = await isAdminOfOrg(claim.admin_address, claim.organization_id); + if (isAdmin) { + const webhooks = await OrganizationWebhook.findAll({ where: { organization_id: claim.organization_id } }); + for (const webhook of webhooks) { + try { + await axios.post(webhook.webhook_url, claim.toJSON()); + console.log(`Webhook fired: ${webhook.webhook_url}`); + } catch (err) { + console.error(`Webhook failed: ${webhook.webhook_url}`, err); + } } + } else { + console.warn('Webhook not fired: admin_address does not match organization_id'); } } return claim;