fix: merge tool access existence oracle

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip committed 2026-07-15 06:05:20 +00:00
commit fe47bf1d58
3 files changed
+195 -39

No files matched your search

@@ -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, <resource>.companyId)` call that is not preceded
* by a `hasCompanyAccess(req, <resource>.companyId)` gate. Sites where the
* This test scans every route file and fails when it finds a company access
* assertion on `req, <resource>.companyId` that is not preceded by a
* `hasCompanyAccess(req, <resource>.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");
// "<file>:<variable>" 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);
}
}
+108 -10
View File
@@ -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 404 for cross-company profile reads, 403 for mutations, 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, {
@@ -2076,15 +2172,15 @@ describeEmbeddedPostgres("tool access service", () => {
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()
+32 -10
View File
@@ -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;
@@ -267,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(),
@@ -285,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,
@@ -447,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);
@@ -467,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, {
@@ -516,16 +527,14 @@ 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);
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);
if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found");
assertCompanyAccess(req, connection.companyId);
assertLookedUpToolResourceAccess(req, connection.companyId, "Tool connection not found");
res.json({ connectionId: connection.id, installs: connection.installs ?? [] });
});
@@ -535,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, {
@@ -559,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({
@@ -596,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 {
@@ -620,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({
@@ -635,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(
@@ -678,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);
@@ -707,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)));
});
@@ -716,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,
@@ -738,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)));
});
@@ -745,16 +763,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);
if (!hasCompanyAccess(req, existing.companyId)) throw notFound("Tool connection not found");
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);
if (!hasCompanyAccess(req, existing.companyId)) throw notFound("Tool connection not found");
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));
@@ -770,8 +786,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);
if (!hasCompanyAccess(req, existing.companyId)) throw notFound("Tool profile not found");
assertCompanyAccess(req, existing.companyId);
assertLookedUpToolResourceAccess(req, existing.companyId, "Tool profile not found");
res.json(await svc.listProfileNewTools(existing.id, existing.companyId));
});
@@ -804,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);
@@ -824,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);
@@ -849,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, {
@@ -870,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, {
@@ -890,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, {
@@ -906,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, {
@@ -922,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, {