From 91669741d2716f233ec2b87adea64565749dc5ee Mon Sep 17 00:00:00 2001 From: Daniel Sauer <81422812+sauerdaniel@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:09:18 +0200 Subject: [PATCH] fix(server): close tool-access cross-tenant ID oracles (#9589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip's company-scoped HTTP routes must reject inaccessible resources before returning resource-specific authorization results. > - The shared `getAccessibleResource` helper established that invariant, but direct tool-access routes still fetched globally unique IDs first and then returned 403 from later authorization checks. > - A signed-in user could therefore distinguish a valid foreign-company resource ID from an unknown ID. > - This change applies the existing tenant-aware lookup gate consistently across direct tool-resource routes and rejects inaccessible OAuth state before callback-specific authorization. ## Linked Issues or Issue Description - No standalone issue exists. This is a security-hardening follow-up to #3967. - **Observed:** a member of company A can submit a known application, connection, profile, profile-entry, or OAuth-state ID belonging to company B and receive a different response than for a random missing ID. - **Expected:** missing and inaccessible foreign resources are indistinguishable at the HTTP boundary. Signed-in instance administrators still require company membership for company-scoped access. - **Reproduction:** create resources in company B, authenticate as an owner of company A without B membership, and call the direct `/api/tool-*` routes using B's IDs. Before this change, affected calls returned 403 while unknown IDs returned 404. ## What Changed - Wrapped direct application, connection, profile, and profile-entry lookups in `server/src/routes/tool-access.ts` with the shared `getAccessibleResource` 404 gate. - Added tenant membership validation to OAuth callback-state lookup before session/role checks, returning the same invalid-state response as an unknown state. - Expanded route regressions across connection/profile endpoint families, including grants, usage, installs, gateway-backed test calls, OAuth, mutations, catalog/activity reads, profile entries, and instance-admin-without-membership access. - Updated application update/delete expectations from cross-tenant 403 to non-enumerating 404 responses. ## Verification After rebasing onto current `master`: - `pnpm exec vitest run src/__tests__/tool-access-service.test.ts` from `server/` — 113 passed. - `pnpm --filter @paperclipai/server typecheck` — previously passed on the same implementation; affected upstream paths were unchanged before this mechanical rebase. ## Risks - Low implementation risk: no schema, migration, or successful same-company response changes. - Intentional behavior change: inaccessible foreign tool-resource IDs now return 404 instead of 403; inaccessible OAuth states return the same 400 body as missing/expired states. - The gate reuses `getAccessibleResource` / `hasCompanyAccess` semantics established by #3967. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `openai-codex/gpt-5.6-sol`; repository, shell, test, TypeScript language-server, and GitHub CLI tool access enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked an existing issue or described the issue in-PR - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket ID - [x] I have run focused tests locally on the final rebased head and they pass - [x] I have added or updated tests where applicable - [x] Documentation update — N/A: internal authorization correction only - [x] I have considered and documented risks above - [ ] All Paperclip CI gates are green on the new rebased head - [x] Greptile's prior review was 5/5 with no open findings - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Daniel Sauer --- .../src/__tests__/tool-access-service.test.ts | 160 ++++++++++++++---- server/src/routes/tool-access.ts | 104 +++++++----- 2 files changed, 187 insertions(+), 77 deletions(-) diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 0f25b8fba3..22a0c05990 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2208,14 +2208,20 @@ describeEmbeddedPostgres("tool access service", () => { .expect(403); }); - it("returns 404 for cross-company profile reads, 403 for mutations, and 404 for missing profiles", async () => { + it("returns 404 for cross-company profile routes and missing profiles", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); - const profile = await toolAccessService(db).createProfile(otherCompany.id, { + const service = toolAccessService(db); + const profile = await service.createProfile(otherCompany.id, { profileKey: `other-profile-${randomUUID()}`, name: "Other company profile", defaultAction: "deny", }); + const entry = await service.addProfileEntry(profile.id, { + selectorType: "tool_name", + effect: "include", + toolName: "read_notes", + }); const app = createRouteApp(db, { type: "board", userId: "member-user", @@ -2233,33 +2239,123 @@ describeEmbeddedPostgres("tool access service", () => { source: "session", }); - 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); - await request(app) - .delete(`/api/tool-profiles/${profile.id}`) - .send({ force: false }) - .expect(403); - await request(app) - .post(`/api/tool-profiles/${profile.id}/new-tools/review`) - .send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] }) - .expect(403); + const crossTenantResponses = [ + await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`), + await request(app) + .patch(`/api/tool-profiles/${profile.id}`) + .send({ name: "Cross-tenant edit" }), + await request(app) + .post(`/api/tool-profiles/${profile.id}/duplicate`) + .send({ name: "Copy" }), + await request(app) + .delete(`/api/tool-profiles/${profile.id}`) + .send({}), + await request(app) + .post(`/api/tool-profiles/${profile.id}/new-tools/review`) + .send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] }), + await request(app) + .post(`/api/tool-profiles/${profile.id}/entries`) + .send({ selectorType: "tool_name", effect: "include", toolName: "write_notes" }), + await request(app) + .patch(`/api/tool-profile-entries/${entry.id}`) + .send({ effect: "exclude" }), + await request(app).delete(`/api/tool-profile-entries/${entry.id}`), + ]; + const missingRes = await request(app).get(`/api/tool-profiles/${randomUUID()}/new-tools`); - await request(createRouteApp(db)).get(`/api/tool-profiles/${randomUUID()}/new-tools`).expect(404); - await request(createRouteApp(db)) - .post(`/api/tool-profiles/${randomUUID()}/duplicate`) - .send({ name: "Missing copy", includeAssignments: false }) - .expect(404); - await request(createRouteApp(db)) - .delete(`/api/tool-profiles/${randomUUID()}`) - .send({ force: false }) - .expect(404); - await request(createRouteApp(db)) - .post(`/api/tool-profiles/${randomUUID()}/new-tools/review`) - .send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }] }) - .expect(404); + expect(crossTenantResponses.map((response) => response.status)).toEqual( + crossTenantResponses.map(() => 404), + ); + expect(missingRes.status).toBe(404); + expect(crossTenantResponses[0]!.body).toEqual(missingRes.body); + }); + + it("returns 404 for cross-company connection routes, including instance admins", async () => { + const allowedCompany = await createCompany(db); + const otherCompany = await createCompany(db); + const service = toolAccessService(db); + const connection = await service.createConnection(otherCompany.id, { + name: "Other company connection", + transport: "mcp_remote", + config: { url: "https://other-company.example/mcp" }, + }); + const oauthState = randomUUID(); + await db.insert(toolOauthStates).values({ + state: oauthState, + companyId: otherCompany.id, + connectionId: connection.id, + codeVerifier: "cross-tenant-code-verifier", + createdByActorType: "user", + createdByActorId: "other-user", + createdBySessionId: null, + expiresAt: new Date(Date.now() + 60_000), + }); + const toolGateway = {} as ToolGatewayService; + const app = createRouteApp(db, { + type: "board", + userId: "member-user", + userName: "Member User", + userEmail: null, + companyIds: [allowedCompany.id], + memberships: [ + { + companyId: allowedCompany.id, + membershipRole: "owner", + status: "active", + }, + ], + isInstanceAdmin: true, + source: "session", + }, toolGateway); + const foreignOAuthRes = await request(app) + .get("/api/tools/oauth/callback") + .query({ state: oauthState, code: "oauth-code" }); + const missingOAuthRes = await request(app) + .get("/api/tools/oauth/callback") + .query({ state: randomUUID(), code: "oauth-code" }); + + const crossTenantResponses = [ + await request(app).post(`/api/tools/oauth/${connection.id}/start`), + await request(app).get(`/api/tool-connections/${connection.id}`), + await request(app).get(`/api/tool-connections/${connection.id}/grants`), + await request(app) + .post(`/api/tool-connections/${connection.id}/grants/installations`) + .send({}), + await request(app).delete(`/api/tool-connections/${connection.id}/grants/${randomUUID()}`), + await request(app).get(`/api/tool-connections/${connection.id}/usage`), + await request(app).get(`/api/tool-connections/${connection.id}/installs`), + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [] }), + await request(app).get(`/api/tool-connections/${connection.id}/test-agents`), + await request(app) + .post(`/api/tool-connections/${connection.id}/test-calls`) + .send({ agentId: randomUUID(), toolName: "read_notes", parameters: {} }), + await request(app) + .get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`), + await request(app) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Cross-tenant edit" }), + await request(app).delete(`/api/tool-connections/${connection.id}`), + await request(app).post(`/api/tool-connections/${connection.id}/health-check`), + await request(app) + .post(`/api/tool-connections/${connection.id}/reconnect`) + .send({ credentialValues: {} }), + await request(app).post(`/api/tool-connections/${connection.id}/catalog/refresh`), + await request(app).get(`/api/tool-connections/${connection.id}/catalog`), + await request(app).get(`/api/tool-connections/${connection.id}/activity?limit=5`), + ]; + expect(foreignOAuthRes.status).toBe(400); + expect(missingOAuthRes.status).toBe(400); + expect(foreignOAuthRes.body).toEqual(missingOAuthRes.body); + const missingRes = await request(app).get(`/api/tool-connections/${randomUUID()}`); + + for (const response of crossTenantResponses) { + expect(response.status).toBe(404); + } + expect(missingRes.status).toBe(404); + expect(crossTenantResponses[1]!.body).toEqual(missingRes.body); + await expect(service.getConnection(connection.id)).resolves.toMatchObject({ id: connection.id }); }); it("installs the safe example fixture idempotently and smokes allow, deny, and audit paths", async () => { @@ -5604,7 +5700,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 application updates and missing applications", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const application = await toolAccessService(db).createApplication(otherCompany.id, { @@ -5635,7 +5731,7 @@ describeEmbeddedPostgres("tool access service", () => { .patch(`/api/tool-applications/${randomUUID()}`) .send({ name: "Missing edit" }); - expect(forbiddenRes.status).toBe(403); + expect(forbiddenRes.status).toBe(404); expect(missingRes.status).toBe(404); }); @@ -5990,7 +6086,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 application deletes and missing applications", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const application = await toolAccessService(db).createApplication(otherCompany.id, { @@ -6017,7 +6113,7 @@ describeEmbeddedPostgres("tool access service", () => { const forbiddenRes = 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(forbiddenRes.status).toBe(404); 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 836f98ca88..0c4bef458e 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -41,8 +41,8 @@ import { updateToolProfileWithEntriesSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { getActorInfo, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; -import { badRequest, forbidden, notFound, unprocessable } from "../errors.js"; +import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js"; +import { badRequest, forbidden, unprocessable } from "../errors.js"; import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; @@ -308,7 +308,8 @@ export function toolAccessRoutes( ); router.post("/tools/oauth/:connectionId/start", async (req, res) => { - const existing = await svc.getConnection(req.params.connectionId as string); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const result = await svc.startOAuth(existing.companyId, existing.id, { redirectUri: oauthRedirectUri(), @@ -324,7 +325,7 @@ export function toolAccessRoutes( const error = typeof req.query.error === "string" ? req.query.error : null; const errorDescription = typeof req.query.error_description === "string" ? req.query.error_description : null; const pendingState = state ? await svc.peekOAuthState(state) : null; - if (!pendingState) { + if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) { throw badRequest("Invalid or expired OAuth state"); } assertToolAppMutationAccess(req, pendingState.companyId); @@ -489,7 +490,8 @@ export function toolAccessRoutes( }); router.patch("/tool-applications/:applicationId", validate(updateToolApplicationSchema), async (req, res) => { - const existing = await svc.getApplication(req.params.applicationId as string); + const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); try { const application = await svc.updateApplication(existing.id, req.body); @@ -509,7 +511,8 @@ export function toolAccessRoutes( }); router.delete("/tool-applications/:applicationId", async (req, res) => { - const existing = await svc.getApplication(req.params.applicationId as string); + const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const application = await svc.deleteApplication(existing.id); await logActivity(db, { @@ -558,23 +561,22 @@ export function toolAccessRoutes( router.get("/tool-connections/:connectionId", async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); - if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); - assertCompanyAccess(req, connection.companyId); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; res.json(connection); }); router.get("/tool-connections/:connectionId/grants", async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); - if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); - assertCompanyAccess(req, connection.companyId); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; res.json(await svc.listConnectionGrants(connection.id, connection.companyId)); }); router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); const body = req.body && typeof req.body === "object" ? req.body as Record : {}; const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : []; @@ -600,7 +602,8 @@ export function toolAccessRoutes( router.delete("/tool-connections/:connectionId/grants/:grantId", async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req)); await logActivity(db, { @@ -617,9 +620,8 @@ export function toolAccessRoutes( router.get("/tool-connections/:connectionId/usage", async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); - if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); - assertCompanyAccess(req, connection.companyId); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; const range = req.query.range === "30d" ? "30d" : req.query.range === undefined || req.query.range === "7d" ? "7d" : null; if (!range) throw badRequest("Usage range must be 7d or 30d"); res.json(await svc.getConnectionUsage(connection.id, range, connection.companyId)); @@ -627,9 +629,8 @@ export function toolAccessRoutes( router.get("/tool-connections/:connectionId/installs", async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); - if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found"); - assertCompanyAccess(req, connection.companyId); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; res.json({ connectionId: connection.id, installs: connection.installs ?? [] }); }); @@ -638,7 +639,8 @@ export function toolAccessRoutes( validate(putToolConnectionInstallsSchema), async (req, res) => { assertBoard(req); - const connection = await svc.getConnection(req.params.connectionId as string); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req)); await logActivity(db, { @@ -662,7 +664,8 @@ export function toolAccessRoutes( res.status(501).json({ error: "Tool gateway service is not configured" }); return; } - const connection = await svc.getConnection(req.params.connectionId as string); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); const rows = await db .select({ @@ -699,7 +702,8 @@ export function toolAccessRoutes( res.status(501).json({ error: "Tool gateway service is not configured" }); return; } - const connection = await svc.getConnection(req.params.connectionId as string); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); await assertCanTestAsAgent(req, connection.companyId, req.body.agentId); try { @@ -723,7 +727,8 @@ export function toolAccessRoutes( res.status(501).json({ error: "Tool gateway service is not configured" }); return; } - const connection = await svc.getConnection(req.params.connectionId as string); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); try { const status = await options.toolGateway.getTestCallStatus({ @@ -738,7 +743,8 @@ export function toolAccessRoutes( }); router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => { - const existing = await svc.getConnection(req.params.connectionId as string); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const connection = await svc.updateConnection(existing.id, req.body); const lifecycleChanges = classifyConnectionUpdate( @@ -781,7 +787,8 @@ export function toolAccessRoutes( }); router.delete("/tool-connections/:connectionId", async (req, res) => { - const existing = await svc.getConnection(req.params.connectionId as string); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const applicationBefore = await svc.getApplication(existing.applicationId); const connection = await svc.archiveConnection(existing.id); @@ -810,7 +817,8 @@ export function toolAccessRoutes( }); router.post("/tool-connections/:connectionId/health-check", async (req, res) => { - const existing = await svc.getConnection(req.params.connectionId as string); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); res.json(await svc.checkHealth(existing.id, getActorInfo(req))); }); @@ -819,7 +827,8 @@ export function toolAccessRoutes( "/tool-connections/:connectionId/reconnect", validate(reconnectToolAppSchema), async (req, res) => { - const existing = await svc.getConnection(req.params.connectionId as string); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const result = await svc.reconnectGalleryApp( existing.id, @@ -841,24 +850,23 @@ export function toolAccessRoutes( ); router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => { - const existing = await svc.getConnection(req.params.connectionId as string); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); res.json(await svc.refreshCatalog(existing.id, getActorInfo(req))); }); router.get("/tool-connections/:connectionId/catalog", async (req, res) => { assertBoard(req); - const existing = await svc.getConnection(req.params.connectionId as string); - if (!hasCompanyAccess(req, existing.companyId)) throw notFound("Tool connection not found"); - assertCompanyAccess(req, existing.companyId); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; 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); - if (!hasCompanyAccess(req, existing.companyId)) throw notFound("Tool connection not found"); - assertCompanyAccess(req, existing.companyId); + const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!existing) return; const limitRaw = Number(req.query.limit ?? 20); const limit = Number.isFinite(limitRaw) ? limitRaw : 20; res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit)); @@ -873,9 +881,8 @@ 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); - if (!hasCompanyAccess(req, existing.companyId)) throw notFound("Tool profile not found"); - assertCompanyAccess(req, existing.companyId); + const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); + if (!existing) return; res.json(await svc.listProfileNewTools(existing.id, existing.companyId)); }); @@ -907,7 +914,8 @@ export function toolAccessRoutes( }); router.patch("/tool-profiles/:profileId", validate(updateToolProfileWithEntriesSchema), async (req, res) => { - const existing = await svc.getProfile(req.params.profileId as string); + const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); try { const profile = await svc.updateProfile(existing.id, req.body); @@ -927,7 +935,8 @@ export function toolAccessRoutes( }); router.post("/tool-profiles/:profileId/duplicate", validate(duplicateToolProfileSchema), async (req, res) => { - const existing = await svc.getProfile(req.params.profileId as string); + const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); try { const profile = await svc.duplicateProfile(existing.id, req.body); @@ -952,7 +961,8 @@ export function toolAccessRoutes( }); router.delete("/tool-profiles/:profileId", validate(deleteToolProfileSchema), async (req, res) => { - const existing = await svc.getProfile(req.params.profileId as string); + const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const result = await svc.deleteProfile(existing.id, req.body); await logActivity(db, { @@ -973,7 +983,8 @@ 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); + const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const result = await svc.reviewProfileNewTools(existing.id, req.body, getActorInfo(req)); await logActivity(db, { @@ -993,7 +1004,8 @@ export function toolAccessRoutes( }); router.post("/tool-profiles/:profileId/entries", validate(createToolProfileEntryForProfileSchema), async (req, res) => { - const existing = await svc.getProfile(req.params.profileId as string); + const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const entry = await svc.addProfileEntry(existing.id, req.body); await logActivity(db, { @@ -1009,7 +1021,8 @@ export function toolAccessRoutes( }); router.patch("/tool-profile-entries/:entryId", validate(updateToolProfileEntrySchema), async (req, res) => { - const existing = await svc.getProfileEntry(req.params.entryId as string); + const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const entry = await svc.updateProfileEntry(existing.id, req.body); await logActivity(db, { @@ -1025,7 +1038,8 @@ export function toolAccessRoutes( }); router.delete("/tool-profile-entries/:entryId", async (req, res) => { - const existing = await svc.getProfileEntry(req.params.entryId as string); + const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found"); + if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); const entry = await svc.deleteProfileEntry(existing.id); await logActivity(db, {