Files
PaperClipAI/server/src/__tests__/plugin-operational-reads-tenant-scope.test.ts
T
DottaandPaperclip 5db5734774 fix(plugins): scope operational reads to the caller's companies (#17435)
## 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>
2026-10-24 09:28:17 -07:00

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,
]);
});
});