Skip to content

Commit dd9c39a

Browse files
committed
Merge remote-tracking branch 'stubbi/fix/multi-tenant-authz-audit'
2 parents 93ba783 + 480a77f commit dd9c39a

5 files changed

Lines changed: 95 additions & 19 deletions

File tree

server/src/realtime/live-events-ws.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ async function authorizeUpgrade(
142142
),
143143
]);
144144

145+
// NOTE: This membership check mirrors assertCompanyAccess() in routes/authz.ts.
146+
// If the access model changes there, update this check to match.
145147
const hasCompanyMembership = memberships.some((row) => row.companyId === companyId);
146148
if (!roleRow && !hasCompanyMembership) return null;
147149

server/src/routes/activity.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Router } from "express";
22
import { z } from "zod";
33
import type { Db } from "@paperclipai/db";
4+
import { heartbeatRuns } from "@paperclipai/db";
5+
import { eq } from "drizzle-orm";
46
import { validate } from "../middleware/validate.js";
57
import { activityService } from "../services/activity.js";
68
import { assertBoard, assertCompanyAccess } from "./authz.js";
@@ -46,6 +48,7 @@ export function activityRoutes(db: Db) {
4648
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
4749
assertBoard(req);
4850
const companyId = req.params.companyId as string;
51+
assertCompanyAccess(req, companyId);
4952
const event = await svc.create({
5053
companyId,
5154
...req.body,
@@ -79,7 +82,18 @@ export function activityRoutes(db: Db) {
7982
});
8083

8184
router.get("/heartbeat-runs/:runId/issues", async (req, res) => {
85+
assertBoard(req);
8286
const runId = req.params.runId as string;
87+
const run = await db
88+
.select({ companyId: heartbeatRuns.companyId })
89+
.from(heartbeatRuns)
90+
.where(eq(heartbeatRuns.id, runId))
91+
.then((rows) => rows[0] ?? null);
92+
if (!run) {
93+
res.status(404).json({ error: "Heartbeat run not found" });
94+
return;
95+
}
96+
assertCompanyAccess(req, run.companyId);
8397
const result = await svc.issuesForRun(runId);
8498
res.json(result);
8599
});

server/src/routes/agents.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,12 @@ export function agentRoutes(db: Db) {
17641764
router.post("/agents/:id/pause", async (req, res) => {
17651765
assertBoard(req);
17661766
const id = req.params.id as string;
1767+
const existing = await svc.getById(id);
1768+
if (!existing) {
1769+
res.status(404).json({ error: "Agent not found" });
1770+
return;
1771+
}
1772+
assertCompanyAccess(req, existing.companyId);
17671773
const agent = await svc.pause(id);
17681774
if (!agent) {
17691775
res.status(404).json({ error: "Agent not found" });
@@ -1787,6 +1793,12 @@ export function agentRoutes(db: Db) {
17871793
router.post("/agents/:id/resume", async (req, res) => {
17881794
assertBoard(req);
17891795
const id = req.params.id as string;
1796+
const existing = await svc.getById(id);
1797+
if (!existing) {
1798+
res.status(404).json({ error: "Agent not found" });
1799+
return;
1800+
}
1801+
assertCompanyAccess(req, existing.companyId);
17901802
const agent = await svc.resume(id);
17911803
if (!agent) {
17921804
res.status(404).json({ error: "Agent not found" });
@@ -1808,6 +1820,12 @@ export function agentRoutes(db: Db) {
18081820
router.post("/agents/:id/terminate", async (req, res) => {
18091821
assertBoard(req);
18101822
const id = req.params.id as string;
1823+
const existing = await svc.getById(id);
1824+
if (!existing) {
1825+
res.status(404).json({ error: "Agent not found" });
1826+
return;
1827+
}
1828+
assertCompanyAccess(req, existing.companyId);
18111829
const agent = await svc.terminate(id);
18121830
if (!agent) {
18131831
res.status(404).json({ error: "Agent not found" });
@@ -1831,6 +1849,12 @@ export function agentRoutes(db: Db) {
18311849
router.delete("/agents/:id", async (req, res) => {
18321850
assertBoard(req);
18331851
const id = req.params.id as string;
1852+
const existing = await svc.getById(id);
1853+
if (!existing) {
1854+
res.status(404).json({ error: "Agent not found" });
1855+
return;
1856+
}
1857+
assertCompanyAccess(req, existing.companyId);
18341858
const agent = await svc.remove(id);
18351859
if (!agent) {
18361860
res.status(404).json({ error: "Agent not found" });
@@ -1852,34 +1876,56 @@ export function agentRoutes(db: Db) {
18521876
router.get("/agents/:id/keys", async (req, res) => {
18531877
assertBoard(req);
18541878
const id = req.params.id as string;
1879+
const agent = await svc.getById(id);
1880+
if (!agent) {
1881+
res.status(404).json({ error: "Agent not found" });
1882+
return;
1883+
}
1884+
assertCompanyAccess(req, agent.companyId);
18551885
const keys = await svc.listKeys(id);
18561886
res.json(keys);
18571887
});
18581888

18591889
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
18601890
assertBoard(req);
18611891
const id = req.params.id as string;
1862-
const key = await svc.createApiKey(id, req.body.name);
1863-
18641892
const agent = await svc.getById(id);
1865-
if (agent) {
1866-
await logActivity(db, {
1867-
companyId: agent.companyId,
1868-
actorType: "user",
1869-
actorId: req.actor.userId ?? "board",
1870-
action: "agent.key_created",
1871-
entityType: "agent",
1872-
entityId: agent.id,
1873-
details: { keyId: key.id, name: key.name },
1874-
});
1893+
if (!agent) {
1894+
res.status(404).json({ error: "Agent not found" });
1895+
return;
18751896
}
1897+
assertCompanyAccess(req, agent.companyId);
1898+
const key = await svc.createApiKey(id, req.body.name);
1899+
1900+
await logActivity(db, {
1901+
companyId: agent.companyId,
1902+
actorType: "user",
1903+
actorId: req.actor.userId ?? "board",
1904+
action: "agent.key_created",
1905+
entityType: "agent",
1906+
entityId: agent.id,
1907+
details: { keyId: key.id, name: key.name },
1908+
});
18761909

18771910
res.status(201).json(key);
18781911
});
18791912

18801913
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
18811914
assertBoard(req);
1915+
const id = req.params.id as string;
18821916
const keyId = req.params.keyId as string;
1917+
const agent = await svc.getById(id);
1918+
if (!agent) {
1919+
res.status(404).json({ error: "Agent not found" });
1920+
return;
1921+
}
1922+
assertCompanyAccess(req, agent.companyId);
1923+
// Verify the key belongs to this agent to prevent cross-agent key revocation
1924+
const keys = await svc.listKeys(id);
1925+
if (!keys.some((k) => k.id === keyId)) {
1926+
res.status(404).json({ error: "Key not found for this agent" });
1927+
return;
1928+
}
18831929
const revoked = await svc.revokeKey(keyId);
18841930
if (!revoked) {
18851931
res.status(404).json({ error: "Key not found" });
@@ -2098,6 +2144,12 @@ export function agentRoutes(db: Db) {
20982144
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
20992145
assertBoard(req);
21002146
const runId = req.params.runId as string;
2147+
const existing = await heartbeat.getRun(runId);
2148+
if (!existing) {
2149+
res.status(404).json({ error: "Heartbeat run not found" });
2150+
return;
2151+
}
2152+
assertCompanyAccess(req, existing.companyId);
21012153
const run = await heartbeat.cancelRun(runId);
21022154

21032155
if (run) {

server/src/routes/authz.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ export function assertBoard(req: Request) {
77
}
88
}
99

10+
export function assertInstanceAdmin(req: Request) {
11+
assertBoard(req);
12+
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
13+
return;
14+
}
15+
throw forbidden("Instance admin access required");
16+
}
17+
1018
export function assertCompanyAccess(req: Request, companyId: string) {
1119
if (req.actor.type === "none") {
1220
throw unauthorized();

server/src/routes/plugins.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
4747
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
4848
import type { ToolRunContext } from "@paperclipai/plugin-sdk";
4949
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
50-
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
50+
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
5151
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
5252

5353
/** UI slot declaration extracted from plugin manifest */
@@ -601,7 +601,7 @@ export function pluginRoutes(
601601
* - `500` — installation succeeded but manifest is missing (indicates a loader bug)
602602
*/
603603
router.post("/plugins/install", async (req, res) => {
604-
assertBoard(req);
604+
assertInstanceAdmin(req);
605605
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
606606

607607
// Input validation
@@ -1228,7 +1228,7 @@ export function pluginRoutes(
12281228
* Errors: 404 if plugin not found, 400 for lifecycle errors
12291229
*/
12301230
router.delete("/plugins/:pluginId", async (req, res) => {
1231-
assertBoard(req);
1231+
assertInstanceAdmin(req);
12321232
const { pluginId } = req.params;
12331233
const purge = req.query.purge === "true";
12341234

@@ -1264,7 +1264,7 @@ export function pluginRoutes(
12641264
* Errors: 404 if plugin not found, 400 for lifecycle errors
12651265
*/
12661266
router.post("/plugins/:pluginId/enable", async (req, res) => {
1267-
assertBoard(req);
1267+
assertInstanceAdmin(req);
12681268
const { pluginId } = req.params;
12691269

12701270
const plugin = await resolvePlugin(registry, pluginId);
@@ -1302,7 +1302,7 @@ export function pluginRoutes(
13021302
* Errors: 404 if plugin not found, 400 for lifecycle errors
13031303
*/
13041304
router.post("/plugins/:pluginId/disable", async (req, res) => {
1305-
assertBoard(req);
1305+
assertInstanceAdmin(req);
13061306
const { pluginId } = req.params;
13071307
const body = req.body as { reason?: string } | undefined;
13081308
const reason = body?.reason;
@@ -1461,7 +1461,7 @@ export function pluginRoutes(
14611461
* Errors: 404 if plugin not found, 400 for lifecycle errors
14621462
*/
14631463
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
1464-
assertBoard(req);
1464+
assertInstanceAdmin(req);
14651465
const { pluginId } = req.params;
14661466
const body = req.body as { version?: string } | undefined;
14671467
const version = body?.version;
@@ -1540,7 +1540,7 @@ export function pluginRoutes(
15401540
* - 404 if plugin not found
15411541
*/
15421542
router.post("/plugins/:pluginId/config", async (req, res) => {
1543-
assertBoard(req);
1543+
assertInstanceAdmin(req);
15441544
const { pluginId } = req.params;
15451545

15461546
const plugin = await resolvePlugin(registry, pluginId);

0 commit comments

Comments
 (0)