mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - One instance hosts many companies, and company boundaries are the main isolation contract > - Plugin worker logs, job runs and webhook deliveries store a `company_id` attribution (NULL for instance scope) > - The plugin diagnostics routes checked board org access, but they did not filter those rows by the caller's companies > - A board member of one company could therefore read another company's log messages, run errors and delivery errors > - This pull request scopes those reads to the caller's companies and keeps the instance-wide view for instance administrators > - The benefit is that plugin diagnostics obey the same tenant boundary as the rest of the plugin routes ## Linked Issues or Issue Description **What happened?** A board user who is a member of only company A could read plugin operational rows attributed to company B. `GET /api/plugins/:pluginId/logs` returned log rows with `company_id` values outside the caller's companies. `GET /api/plugins/:pluginId/jobs/:jobId/runs` returned other companies' run rows, including their error text and run logs. `GET /api/plugins/:pluginId/dashboard` returned other companies' recent job runs and webhook deliveries. The routes only required board org access and filtered on plugin id or job id. **Expected behavior** A board member sees only operational rows for companies the member can access. An instance administrator (and the local implicit board) keeps the instance-wide view, which includes instance-scope rows where `company_id` is NULL. **Steps to reproduce** 1. Create two companies, A and B, on an authenticated instance. 2. Install a plugin and produce a log row, a job run and a webhook delivery for company B. 3. Sign in as a board user who is a member of company A only. 4. Call `GET /api/plugins/:pluginId/logs`, `GET /api/plugins/:pluginId/jobs/:jobId/runs` and `GET /api/plugins/:pluginId/dashboard`. Before this change, the responses included company B rows. After this change, they do not. **Paperclip version or commit** Reproduced on `2e6d3f7` (`master`, 2026-10-24). The report came from a scoped security audit of `52a66af29ddccf1fcac25f1f6efa9e9958e11fbd`. **Deployment mode** Self-hosted server, authenticated mode. The defect does not depend on the deployment mode, but `local_trusted` always yields an instance-admin actor and hides it. ## What Changed - Added `pluginOperationalReadCompanyIds()` in `server/src/routes/plugins.ts`. It returns `null` for instance admins and the local implicit board, and the actor's company ids for every other board actor. - Scoped `GET /api/plugins/:pluginId/logs` with `inArray(pluginLogs.companyId, companyIds)`. - Scoped `GET /api/plugins/:pluginId/jobs/:jobId/runs` through a new optional `companyIds` argument on `pluginJobStore.listRunsByJob()`. - Scoped the dashboard's recent job runs through the same optional argument on `pluginJobStore.listRunsByPlugin()`, and scoped its webhook delivery query. - Added `server/src/__tests__/plugin-operational-reads-tenant-scope.test.ts`. It starts embedded Postgres, seeds two companies plus instance-scope rows, and asserts the single-company view and the instance-admin view for all three routes. ## Verification - Red first: with the test added and the route unchanged, 3 of 4 cases failed and the response contained the company B rows. - Green after the fix: `pnpm exec vitest run server/src/__tests__/plugin-operational-reads-tenant-scope.test.ts` passed (4 tests). - Related suites: `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/plugin-tenant-isolation.test.ts server/src/__tests__/plugin-install-route-security.test.ts` passed (59 tests). - Typecheck: `pnpm --filter @paperclipai/server typecheck` passed. ## Risks - Behavior change for non-admin board members: they no longer see instance-scope rows (`company_id` NULL) or rows for companies they cannot access. Instance administrators keep the full view. For a single-company instance the visible rows are the same, except instance-scope rows. - No schema or migration change. No API shape change. Row-level filtering only. - A plugin that writes diagnostics without a `company_id` will show fewer rows to non-admin users. That is the intended tenant boundary. ## Model Used - Provider: OpenRouter - Model: `deepseek/deepseek-v4.1-flash-1017` (DeepSeek V4.1 Flash) - Harness: OpenCode, with tool use, code execution, and multi-agent delegation - Reasoning: enabled (thinking mode) - Context window: not exposed by the harness ## 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 (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
245 lines
8.5 KiB
TypeScript
245 lines
8.5 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
companies,
|
|
createDb,
|
|
pluginJobRuns,
|
|
pluginJobs,
|
|
pluginLogs,
|
|
pluginWebhookDeliveries,
|
|
plugins,
|
|
} from "@paperclipai/db";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { pluginRoutes } from "../routes/plugins.js";
|
|
import { pluginJobStore } from "../services/plugin-job-store.js";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
|
|
/**
|
|
* Regression coverage for tenant scoping on plugin operational reads.
|
|
*
|
|
* `plugin_logs`, `plugin_job_runs` and `plugin_webhook_deliveries` all carry a
|
|
* `company_id` attribution (NULL for instance-scope rows). A board member of
|
|
* one company must not be able to read rows attributed to another company
|
|
* through the plugin diagnostics routes; instance administrators keep the
|
|
* instance-wide view.
|
|
*/
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping plugin operational-read tenant-scope tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
function issuePrefix(id: string) {
|
|
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
|
}
|
|
|
|
type Actor = {
|
|
type: "board";
|
|
source: string;
|
|
userId: string;
|
|
companyIds: string[];
|
|
memberships: Array<{ companyId: string; membershipRole: string; status: string }>;
|
|
isInstanceAdmin: boolean;
|
|
};
|
|
|
|
function buildApp(actor: Actor, db: ReturnType<typeof createDb>) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as unknown as { actor: Actor }).actor = actor;
|
|
next();
|
|
});
|
|
app.use(
|
|
"/api",
|
|
pluginRoutes(
|
|
db,
|
|
{} as never,
|
|
{ jobStore: pluginJobStore(db) } as never,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
),
|
|
);
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describeEmbeddedPostgres("plugin operational reads are company-scoped", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
const companyA = randomUUID();
|
|
const companyB = randomUUID();
|
|
const pluginId = randomUUID();
|
|
const jobId = randomUUID();
|
|
|
|
const logA = randomUUID();
|
|
const logB = randomUUID();
|
|
const logInstance = randomUUID();
|
|
|
|
const runA = randomUUID();
|
|
const runB = randomUUID();
|
|
const runInstance = randomUUID();
|
|
|
|
const deliveryA = randomUUID();
|
|
const deliveryB = randomUUID();
|
|
const deliveryInstance = randomUUID();
|
|
|
|
const boardA: Actor = {
|
|
type: "board",
|
|
source: "session",
|
|
userId: `user-${randomUUID()}`,
|
|
companyIds: [companyA],
|
|
memberships: [{ companyId: companyA, membershipRole: "owner", status: "active" }],
|
|
isInstanceAdmin: false,
|
|
};
|
|
|
|
const instanceAdmin: Actor = {
|
|
type: "board",
|
|
source: "session",
|
|
userId: `admin-${randomUUID()}`,
|
|
companyIds: [],
|
|
memberships: [],
|
|
isInstanceAdmin: true,
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-operational-reads-");
|
|
db = createDb(tempDb.connectionString);
|
|
|
|
await db.insert(companies).values([
|
|
{ id: companyA, name: `Company A ${companyA}`, issuePrefix: issuePrefix(companyA) },
|
|
{ id: companyB, name: `Company B ${companyB}`, issuePrefix: issuePrefix(companyB) },
|
|
]);
|
|
|
|
await db.insert(plugins).values({
|
|
id: pluginId,
|
|
pluginKey: "paperclip.operational-reads-test",
|
|
packageName: "@paperclipai/plugin-operational-reads-test",
|
|
version: "0.0.1",
|
|
apiVersion: 1,
|
|
categories: ["automation"],
|
|
manifestJson: {
|
|
id: "paperclip.operational-reads-test",
|
|
apiVersion: 1,
|
|
name: "Operational reads test",
|
|
version: "0.0.1",
|
|
entrypoints: {},
|
|
capabilities: [],
|
|
} as never,
|
|
status: "ready",
|
|
});
|
|
|
|
await db.insert(pluginJobs).values({
|
|
id: jobId,
|
|
pluginId,
|
|
jobKey: "sync",
|
|
schedule: "0 * * * *",
|
|
});
|
|
|
|
const base = Date.now();
|
|
await db.insert(pluginLogs).values([
|
|
{ id: logA, pluginId, companyId: companyA, level: "info", message: "company A log", createdAt: new Date(base) },
|
|
{ id: logB, pluginId, companyId: companyB, level: "warn", message: "company B secret log", createdAt: new Date(base + 1_000) },
|
|
{ id: logInstance, pluginId, companyId: null, level: "debug", message: "instance log", createdAt: new Date(base + 2_000) },
|
|
]);
|
|
|
|
await db.insert(pluginJobRuns).values([
|
|
{ id: runA, jobId, pluginId, companyId: companyA, trigger: "manual", status: "succeeded", createdAt: new Date(base) },
|
|
{ id: runB, jobId, pluginId, companyId: companyB, trigger: "manual", status: "failed", error: "company B failure", logs: ["company B run log"], createdAt: new Date(base + 1_000) },
|
|
{ id: runInstance, jobId, pluginId, companyId: null, trigger: "scheduled", status: "succeeded", createdAt: new Date(base + 2_000) },
|
|
]);
|
|
|
|
await db.insert(pluginWebhookDeliveries).values([
|
|
{ id: deliveryA, pluginId, companyId: companyA, webhookKey: "hook", status: "succeeded", payload: { who: "A" }, createdAt: new Date(base) },
|
|
{ id: deliveryB, pluginId, companyId: companyB, webhookKey: "hook", status: "failed", error: "company B delivery error", payload: { who: "B" }, createdAt: new Date(base + 1_000) },
|
|
{ id: deliveryInstance, pluginId, companyId: null, webhookKey: "hook", status: "succeeded", payload: { who: "instance" }, createdAt: new Date(base + 2_000) },
|
|
]);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
// Fixtures are seeded once for the suite; nothing mutates them.
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.delete(pluginWebhookDeliveries);
|
|
await db.delete(pluginJobRuns);
|
|
await db.delete(pluginJobs);
|
|
await db.delete(pluginLogs);
|
|
await db.delete(plugins);
|
|
await db.delete(companies);
|
|
await db.$client.end({ timeout: 1 }).catch(() => {});
|
|
await tempDb?.cleanup();
|
|
tempDb = null;
|
|
});
|
|
|
|
it("returns only the caller's company logs to a single-company board member", async () => {
|
|
const app = buildApp(boardA, db);
|
|
|
|
const res = await request(app).get(`/api/plugins/${pluginId}/logs`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.map((row: { id: string }) => row.id)).toEqual([logA]);
|
|
expect(res.body[0].companyId).toBe(companyA);
|
|
expect(JSON.stringify(res.body)).not.toContain("company B secret log");
|
|
});
|
|
|
|
it("returns only the caller's company job runs to a single-company board member", async () => {
|
|
const app = buildApp(boardA, db);
|
|
|
|
const res = await request(app).get(`/api/plugins/${pluginId}/jobs/${jobId}/runs`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.map((row: { id: string }) => row.id)).toEqual([runA]);
|
|
expect(JSON.stringify(res.body)).not.toContain("company B failure");
|
|
});
|
|
|
|
it("scopes dashboard job runs and webhook deliveries to the caller's company", async () => {
|
|
const app = buildApp(boardA, db);
|
|
|
|
const res = await request(app).get(`/api/plugins/${pluginId}/dashboard`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.recentJobRuns.map((row: { id: string }) => row.id)).toEqual([runA]);
|
|
expect(res.body.recentWebhookDeliveries.map((row: { id: string }) => row.id)).toEqual([
|
|
deliveryA,
|
|
]);
|
|
expect(JSON.stringify(res.body)).not.toContain("company B");
|
|
});
|
|
|
|
it("keeps the instance-wide view for instance administrators", async () => {
|
|
const app = buildApp(instanceAdmin, db);
|
|
|
|
const logs = await request(app).get(`/api/plugins/${pluginId}/logs`);
|
|
const runs = await request(app).get(`/api/plugins/${pluginId}/jobs/${jobId}/runs`);
|
|
const dashboard = await request(app).get(`/api/plugins/${pluginId}/dashboard`);
|
|
|
|
expect(logs.status).toBe(200);
|
|
expect(logs.body.map((row: { id: string }) => row.id)).toEqual([logInstance, logB, logA]);
|
|
|
|
expect(runs.status).toBe(200);
|
|
expect(runs.body.map((row: { id: string }) => row.id)).toEqual([runInstance, runB, runA]);
|
|
|
|
expect(dashboard.status).toBe(200);
|
|
expect(dashboard.body.recentJobRuns.map((row: { id: string }) => row.id)).toEqual([
|
|
runInstance,
|
|
runB,
|
|
runA,
|
|
]);
|
|
expect(dashboard.body.recentWebhookDeliveries.map((row: { id: string }) => row.id)).toEqual([
|
|
deliveryInstance,
|
|
deliveryB,
|
|
deliveryA,
|
|
]);
|
|
});
|
|
});
|