From c21129e3a85e03697836764f7cc7a86c10c95b0c Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 15 Jul 2026 00:36:39 +0000 Subject: [PATCH 1/2] fix: hide tool resource tenant existence Co-authored-by: Paperclip --- server/src/routes/tool-access.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index a3229cfba02..b7077bbe8ae 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -39,8 +39,8 @@ import { updateToolProfileWithEntriesSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { getActorInfo, assertBoard, assertCompanyAccess } from "./authz.js"; -import { badRequest, forbidden, unprocessable } from "../errors.js"; +import { getActorInfo, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; +import { badRequest, forbidden, notFound, unprocessable } from "../errors.js"; import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; @@ -212,6 +212,13 @@ export function toolAccessRoutes( } } + function assertLookedUpToolResourceAccess(req: Request, companyId: string, notFoundMessage: string) { + if (!hasCompanyAccess(req, companyId)) { + throw notFound(notFoundMessage); + } + assertCompanyAccess(req, companyId); + } + router.get("/companies/:companyId/tools/gallery", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; @@ -516,14 +523,14 @@ export function toolAccessRoutes( router.get("/tool-connections/:connectionId", async (req, res) => { assertBoard(req); const connection = await svc.getConnection(req.params.connectionId as string); - assertCompanyAccess(req, connection.companyId); + assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found"); res.json(connection); }); router.get("/tool-connections/:connectionId/installs", async (req, res) => { assertBoard(req); const connection = await svc.getConnection(req.params.connectionId as string); - assertCompanyAccess(req, connection.companyId); + assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found"); res.json({ connectionId: connection.id, installs: connection.installs ?? [] }); }); @@ -743,14 +750,14 @@ export function toolAccessRoutes( router.get("/tool-connections/:connectionId/catalog", async (req, res) => { assertBoard(req); const existing = await svc.getConnection(req.params.connectionId as string); - assertCompanyAccess(req, existing.companyId); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); res.json({ catalog: await svc.listCatalog(existing.id, existing.companyId) }); }); router.get("/tool-connections/:connectionId/activity", async (req, res) => { assertBoard(req); const existing = await svc.getConnection(req.params.connectionId as string); - assertCompanyAccess(req, existing.companyId); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); const limitRaw = Number(req.query.limit ?? 20); const limit = Number.isFinite(limitRaw) ? limitRaw : 20; res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit)); @@ -766,7 +773,7 @@ export function toolAccessRoutes( router.get("/tool-profiles/:profileId/new-tools", async (req, res) => { assertBoard(req); const existing = await svc.getProfile(req.params.profileId as string); - assertCompanyAccess(req, existing.companyId); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found"); res.json(await svc.listProfileNewTools(existing.id, existing.companyId)); }); From 8c939475bbce7a7a77454d43c17613d6159a9520 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 15 Jul 2026 00:58:06 +0000 Subject: [PATCH 2/2] fix: close tool wrapper existence oracle Co-authored-by: Paperclip --- .../authz-existence-oracle-guard.test.ts | 74 ++++++++--- .../src/__tests__/tool-access-service.test.ts | 120 ++++++++++++++++-- server/src/routes/tool-access.ts | 20 +++ 3 files changed, 184 insertions(+), 30 deletions(-) diff --git a/server/src/__tests__/authz-existence-oracle-guard.test.ts b/server/src/__tests__/authz-existence-oracle-guard.test.ts index a6d1f616480..459003ba2ee 100644 --- a/server/src/__tests__/authz-existence-oracle-guard.test.ts +++ b/server/src/__tests__/authz-existence-oracle-guard.test.ts @@ -7,11 +7,12 @@ import { fileURLToPath } from "node:url"; * Static guard against the cross-tenant existence oracle. * * Route handlers that look a resource up by id and then call - * `assertCompanyAccess(req, resource.companyId)` leak resource existence - * across tenants: missing ids return 404 while cross-tenant ids return 403, - * letting any authenticated user enumerate other tenants' ids. The required - * pattern (documented on `hasCompanyAccess` in routes/authz.ts) folds the - * access check into the existence check: + * `assertCompanyAccess(req, resource.companyId)`, or a local wrapper helper + * that internally calls it, leak resource existence across tenants: missing ids + * return 404 while cross-tenant ids return 403, letting any authenticated user + * enumerate other tenants' ids. The required pattern (documented on + * `hasCompanyAccess` in routes/authz.ts) folds the access check into the + * existence check: * * const issue = await svc.getById(id); * if (!issue || !hasCompanyAccess(req, issue.companyId)) { @@ -20,9 +21,9 @@ import { fileURLToPath } from "node:url"; * } * assertCompanyAccess(req, issue.companyId); // write paths only * - * This test scans every route file and fails when it finds an - * `assertCompanyAccess(req, .companyId)` call that is not preceded - * by a `hasCompanyAccess(req, .companyId)` gate. Sites where the + * This test scans every route file and fails when it finds a company access + * assertion on `req, .companyId` that is not preceded by a + * `hasCompanyAccess(req, .companyId)`-style gate. Sites where the * companyId comes from request input (params/body) rather than a looked-up * resource carry no oracle and belong on the allowlist below. */ @@ -30,8 +31,8 @@ import { fileURLToPath } from "node:url"; const ROUTES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "routes"); // ":" call sites where the companyId is request input, not a -// discovered resource — no existence oracle to close. Add new entries only -// when the value cannot reveal whether a cross-tenant resource exists. +// discovered resource. Add new entries only when the value cannot reveal +// whether a cross-tenant resource exists. const REQUEST_INPUT_ALLOWLIST = new Set([ "companies.ts:target", // import target chosen by the caller (req.body.target) "plugins.ts:runContext", // run context supplied in the request body @@ -39,35 +40,70 @@ const REQUEST_INPUT_ALLOWLIST = new Set([ const GATE_LOOKBACK_LINES = 12; +const COMPANY_ACCESS_ASSERTION_HELPERS = [ + "assertCompanyAccess", + "assertBoardToolPermission", + "assertBoardAnyToolPermission", + "assertToolAppMutationAccess", + "assertToolsAdmin", + "assertToolsRuntimeManage", +] as const; + +const LOOKED_UP_RESOURCE_GATE_HELPERS = [ + "hasCompanyAccess", + "assertLookedUpToolResourceAccess", +] as const; + +function helperAlternation(helpers: readonly string[]) { + return helpers.join("|"); +} + +const companyAccessAssertionPattern = new RegExp( + `\\b(${helperAlternation(COMPANY_ACCESS_ASSERTION_HELPERS)})\\(req,\\s*(\\w+)\\.companyId\\b`, +); + +function gatePatternFor(variable: string) { + return new RegExp( + `\\b(${helperAlternation(LOOKED_UP_RESOURCE_GATE_HELPERS)})\\(req,\\s*${variable}\\.companyId\\b`, + ); +} + +function assertionPatternFor(variable: string) { + return new RegExp( + `\\b(${helperAlternation(COMPANY_ACCESS_ASSERTION_HELPERS)})\\(req,\\s*${variable}\\.companyId\\b`, + ); +} + function findUngatedSites() { const ungated: string[] = []; for (const file of readdirSync(ROUTES_DIR).filter((name) => name.endsWith(".ts"))) { if (file === "authz.ts") continue; const lines = readFileSync(join(ROUTES_DIR, file), "utf8").split("\n"); lines.forEach((line, index) => { - const match = line.match(/assertCompanyAccess\(req,\s*(\w+)\.companyId\)/); + const match = line.match(companyAccessAssertionPattern); if (!match) return; - const variable = match[1]; + const [, helper, variable] = match; + if (!helper || !variable) return; const lookback = lines .slice(Math.max(0, index - GATE_LOOKBACK_LINES), index) .join("\n"); - if (new RegExp(`hasCompanyAccess\\(req,\\s*${variable}\\.companyId\\)`).test(lookback)) return; + if (gatePatternFor(variable).test(lookback)) return; if (REQUEST_INPUT_ALLOWLIST.has(`${file}:${variable}`)) return; - ungated.push(`${file}:${index + 1} (${variable}.companyId)`); + ungated.push(`${file}:${index + 1} (${helper} on ${variable}.companyId)`); }); } return ungated; } describe("cross-tenant existence oracle guard", () => { - it("requires a hasCompanyAccess gate before assertCompanyAccess on looked-up resources", () => { + it("requires a 404 gate before company access wrappers on looked-up resources", () => { const ungated = findUngatedSites(); expect( ungated, - "assertCompanyAccess on a looked-up resource without a hasCompanyAccess 404 gate " - + "reintroduces the cross-tenant existence oracle (403 vs 404). " + "Company access wrappers on a looked-up resource without a hasCompanyAccess 404 gate " + + "reintroduce the cross-tenant existence oracle (403 vs 404). " + "Apply the two-step pattern documented on hasCompanyAccess in routes/authz.ts, " - + "or — only if the companyId is request input — add the site to REQUEST_INPUT_ALLOWLIST. " + + "or, only if the companyId is request input, add the site to REQUEST_INPUT_ALLOWLIST. " + `Offending sites: ${ungated.join(", ")}`, ).toEqual([]); }); @@ -77,7 +113,7 @@ describe("cross-tenant existence oracle guard", () => { for (const entry of REQUEST_INPUT_ALLOWLIST) { const [file, variable] = entry.split(":"); const source = readFileSync(join(ROUTES_DIR, file!), "utf8"); - if (!new RegExp(`assertCompanyAccess\\(req,\\s*${variable}\\.companyId\\)`).test(source)) { + if (!variable || !assertionPatternFor(variable).test(source)) { stale.push(entry); } } diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index d81282e9baf..0ef15015965 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -1146,6 +1146,102 @@ describeEmbeddedPostgres("tool access service", () => { }); }); + it("returns identical 404s for cross-tenant and missing wrapper-gated connection lookups", async () => { + const ownerCompany = await createCompany(db); + const outsiderCompany = await createCompany(db); + const userId = `tool-outsider-${randomUUID()}`; + await grantBoardUser(db, outsiderCompany.id, userId, ["tools:use", "tools:manage_connections"]); + const actor = boardSessionActor(outsiderCompany.id, "operator", userId); + const agent = await createAgent(db, ownerCompany.id); + const { connection } = await createRemoteToolFixture(db, ownerCompany.id); + const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" })); + const missingConnectionId = randomUUID(); + + const cases: Array<{ + name: string; + request: (connectionId: string) => request.Test; + }> = [ + { + name: "GET /tool-connections/:connectionId/test-agents", + request: (connectionId) => request(app).get(`/api/tool-connections/${connectionId}/test-agents`), + }, + { + name: "POST /tool-connections/:connectionId/test-calls", + request: (connectionId) => request(app) + .post(`/api/tool-connections/${connectionId}/test-calls`) + .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } }), + }, + { + name: "PUT /tool-connections/:connectionId/installs", + request: (connectionId) => request(app) + .put(`/api/tool-connections/${connectionId}/installs`) + .send({ installs: [] }), + }, + { + name: "POST /tools/oauth/:connectionId/start", + request: (connectionId) => request(app).post(`/api/tools/oauth/${connectionId}/start`), + }, + ]; + + for (const testCase of cases) { + const crossTenant = await testCase.request(connection.id); + const missing = await testCase.request(missingConnectionId); + expect(crossTenant.status, testCase.name).toBe(404); + expect(crossTenant.body, testCase.name).toEqual(missing.body); + } + }); + + it("returns identical 404s for cross-tenant and missing wrapper-gated application lookups", async () => { + const ownerCompany = await createCompany(db); + const outsiderCompany = await createCompany(db); + const userId = `app-outsider-${randomUUID()}`; + await grantBoardUser(db, outsiderCompany.id, userId, ["tools:admin"]); + const actor = boardSessionActor(outsiderCompany.id, "operator", userId); + const { application } = await createRemoteToolFixture(db, ownerCompany.id); + const app = createRouteApp(db, actor); + const missingApplicationId = randomUUID(); + + const cases: Array<{ + name: string; + request: (applicationId: string) => request.Test; + }> = [ + { + name: "PATCH /tool-applications/:applicationId", + request: (applicationId) => request(app) + .patch(`/api/tool-applications/${applicationId}`) + .send({ name: `Renamed ${randomUUID()}` }), + }, + { + name: "DELETE /tool-applications/:applicationId", + request: (applicationId) => request(app).delete(`/api/tool-applications/${applicationId}`), + }, + ]; + + for (const testCase of cases) { + const crossTenant = await testCase.request(application.id); + const missing = await testCase.request(missingApplicationId); + expect(crossTenant.status, testCase.name).toBe(404); + expect(crossTenant.body, testCase.name).toEqual(missing.body); + } + }); + + it("still returns 403 for same-company wrapper permission denials", async () => { + const company = await createCompany(db); + const userId = `tool-denied-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, []); + const { connection } = await createRemoteToolFixture(db, company.id); + const app = createRouteApp( + db, + boardSessionActor(company.id, "operator", userId), + createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), + ); + + const res = await request(app).get(`/api/tool-connections/${connection.id}/test-agents`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Missing one of permissions"); + }); + it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; @@ -2047,7 +2143,7 @@ describeEmbeddedPostgres("tool access service", () => { .expect(403); }); - it("returns 403 for cross-company profile routes and 404 for missing profiles", async () => { + it("returns 404 for cross-company and missing profile routes", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const profile = await toolAccessService(db).createProfile(otherCompany.id, { @@ -2072,19 +2168,19 @@ describeEmbeddedPostgres("tool access service", () => { source: "session", }); - await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`).expect(403); + await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`).expect(404); await request(app) .post(`/api/tool-profiles/${profile.id}/duplicate`) .send({ name: "Forbidden copy", includeAssignments: false }) - .expect(403); + .expect(404); await request(app) .delete(`/api/tool-profiles/${profile.id}`) .send({ force: false }) - .expect(403); + .expect(404); await request(app) .post(`/api/tool-profiles/${profile.id}/new-tools/review`) .send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] }) - .expect(403); + .expect(404); await request(createRouteApp(db)).get(`/api/tool-profiles/${randomUUID()}/new-tools`).expect(404); await request(createRouteApp(db)) @@ -4726,7 +4822,7 @@ describeEmbeddedPostgres("tool access service", () => { }); }); - it("returns 403 for cross-company application updates and 404 for missing applications", async () => { + it("returns 404 for cross-company and missing application updates", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const application = await toolAccessService(db).createApplication(otherCompany.id, { @@ -4750,14 +4846,15 @@ describeEmbeddedPostgres("tool access service", () => { source: "session", }); - const forbiddenRes = await request(app) + const crossTenantRes = await request(app) .patch(`/api/tool-applications/${application.id}`) .send({ name: "Forbidden edit" }); const missingRes = await request(createRouteApp(db)) .patch(`/api/tool-applications/${randomUUID()}`) .send({ name: "Missing edit" }); - expect(forbiddenRes.status).toBe(403); + expect(crossTenantRes.status).toBe(404); + expect(crossTenantRes.body).toEqual(missingRes.body); expect(missingRes.status).toBe(404); }); @@ -5082,7 +5179,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(remainingConnection).toHaveLength(0); }); - it("returns 403 for cross-company application deletes and 404 for missing applications", async () => { + it("returns 404 for cross-company and missing application deletes", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const application = await toolAccessService(db).createApplication(otherCompany.id, { @@ -5106,10 +5203,11 @@ describeEmbeddedPostgres("tool access service", () => { source: "session", }); - const forbiddenRes = await request(app).delete(`/api/tool-applications/${application.id}`); + const crossTenantRes = await request(app).delete(`/api/tool-applications/${application.id}`); const missingRes = await request(createRouteApp(db)).delete(`/api/tool-applications/${randomUUID()}`); - expect(forbiddenRes.status).toBe(403); + expect(crossTenantRes.status).toBe(404); + expect(crossTenantRes.body).toEqual(missingRes.body); expect(missingRes.status).toBe(404); const stillThere = await db .select() diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index b7077bbe8ae..28b0f2bbf74 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -274,6 +274,7 @@ export function toolAccessRoutes( router.post("/tools/oauth/:connectionId/start", async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); assertToolAppMutationAccess(req, existing.companyId); const result = await svc.startOAuth(existing.companyId, existing.id, { redirectUri: oauthRedirectUri(), @@ -292,6 +293,7 @@ export function toolAccessRoutes( if (!pendingState) { throw badRequest("Invalid or expired OAuth state"); } + assertLookedUpToolResourceAccess(req, pendingState.companyId, "Invalid or expired OAuth state"); assertToolAppMutationAccess(req, pendingState.companyId); const result = await svc.completeOAuthCallback({ state, @@ -454,6 +456,7 @@ export function toolAccessRoutes( router.patch("/tool-applications/:applicationId", validate(updateToolApplicationSchema), async (req, res) => { const existing = await svc.getApplication(req.params.applicationId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool application not found"); assertToolAppMutationAccess(req, existing.companyId); try { const application = await svc.updateApplication(existing.id, req.body); @@ -474,6 +477,7 @@ export function toolAccessRoutes( router.delete("/tool-applications/:applicationId", async (req, res) => { const existing = await svc.getApplication(req.params.applicationId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool application not found"); assertToolAppMutationAccess(req, existing.companyId); const application = await svc.deleteApplication(existing.id); await logActivity(db, { @@ -540,6 +544,7 @@ export function toolAccessRoutes( async (req, res) => { assertBoard(req); const connection = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found"); await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req)); await logActivity(db, { @@ -564,6 +569,7 @@ export function toolAccessRoutes( return; } const connection = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found"); await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); const rows = await db .select({ @@ -601,6 +607,7 @@ export function toolAccessRoutes( return; } const connection = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found"); await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); await assertCanTestAsAgent(req, connection.companyId, req.body.agentId); try { @@ -625,6 +632,7 @@ export function toolAccessRoutes( return; } const connection = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found"); await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); try { const status = await options.toolGateway.getTestCallStatus({ @@ -640,6 +648,7 @@ export function toolAccessRoutes( router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); assertToolAppMutationAccess(req, existing.companyId); const connection = await svc.updateConnection(existing.id, req.body); const lifecycleChanges = classifyConnectionUpdate( @@ -683,6 +692,7 @@ export function toolAccessRoutes( router.delete("/tool-connections/:connectionId", async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); assertToolAppMutationAccess(req, existing.companyId); const applicationBefore = await svc.getApplication(existing.applicationId); const connection = await svc.archiveConnection(existing.id); @@ -712,6 +722,7 @@ export function toolAccessRoutes( router.post("/tool-connections/:connectionId/health-check", async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); assertToolAppMutationAccess(req, existing.companyId); res.json(await svc.checkHealth(existing.id, getActorInfo(req))); }); @@ -721,6 +732,7 @@ export function toolAccessRoutes( validate(reconnectToolAppSchema), async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); assertToolAppMutationAccess(req, existing.companyId); const result = await svc.reconnectGalleryApp( existing.id, @@ -743,6 +755,7 @@ export function toolAccessRoutes( router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => { const existing = await svc.getConnection(req.params.connectionId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool connection not found"); assertToolAppMutationAccess(req, existing.companyId); res.json(await svc.refreshCatalog(existing.id, getActorInfo(req))); }); @@ -806,6 +819,7 @@ export function toolAccessRoutes( router.patch("/tool-profiles/:profileId", validate(updateToolProfileWithEntriesSchema), async (req, res) => { const existing = await svc.getProfile(req.params.profileId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found"); assertToolAppMutationAccess(req, existing.companyId); try { const profile = await svc.updateProfile(existing.id, req.body); @@ -826,6 +840,7 @@ export function toolAccessRoutes( router.post("/tool-profiles/:profileId/duplicate", validate(duplicateToolProfileSchema), async (req, res) => { const existing = await svc.getProfile(req.params.profileId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found"); assertToolAppMutationAccess(req, existing.companyId); try { const profile = await svc.duplicateProfile(existing.id, req.body); @@ -851,6 +866,7 @@ export function toolAccessRoutes( router.delete("/tool-profiles/:profileId", validate(deleteToolProfileSchema), async (req, res) => { const existing = await svc.getProfile(req.params.profileId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found"); assertToolAppMutationAccess(req, existing.companyId); const result = await svc.deleteProfile(existing.id, req.body); await logActivity(db, { @@ -872,6 +888,7 @@ export function toolAccessRoutes( router.post("/tool-profiles/:profileId/new-tools/review", validate(reviewToolProfileNewToolsSchema), async (req, res) => { const existing = await svc.getProfile(req.params.profileId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found"); assertToolAppMutationAccess(req, existing.companyId); const result = await svc.reviewProfileNewTools(existing.id, req.body, getActorInfo(req)); await logActivity(db, { @@ -892,6 +909,7 @@ export function toolAccessRoutes( router.post("/tool-profiles/:profileId/entries", validate(createToolProfileEntryForProfileSchema), async (req, res) => { const existing = await svc.getProfile(req.params.profileId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found"); assertToolAppMutationAccess(req, existing.companyId); const entry = await svc.addProfileEntry(existing.id, req.body); await logActivity(db, { @@ -908,6 +926,7 @@ export function toolAccessRoutes( router.patch("/tool-profile-entries/:entryId", validate(updateToolProfileEntrySchema), async (req, res) => { const existing = await svc.getProfileEntry(req.params.entryId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile entry not found"); assertToolAppMutationAccess(req, existing.companyId); const entry = await svc.updateProfileEntry(existing.id, req.body); await logActivity(db, { @@ -924,6 +943,7 @@ export function toolAccessRoutes( router.delete("/tool-profile-entries/:entryId", async (req, res) => { const existing = await svc.getProfileEntry(req.params.entryId as string); + assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile entry not found"); assertToolAppMutationAccess(req, existing.companyId); const entry = await svc.deleteProfileEntry(existing.id); await logActivity(db, {