fix(server): close tool-access cross-tenant ID oracles (#9589)

## 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 <sauerdaniel@users.noreply.github.com>
This commit is contained in:
Daniel Sauer 2026-08-13 01:09:18 +02:00 committed by GitHub
parent 1f7959bc69
commit 91669741d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 187 additions and 77 deletions

View File

@ -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()

View File

@ -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<string, unknown> : {};
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, {