Files
PaperClipAI/server/src/services/plugin-job-store.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

487 lines
15 KiB
TypeScript

/**
* Plugin Job Store — persistence layer for scheduled plugin jobs and their
* execution history.
*
* This service manages the `plugin_jobs` and `plugin_job_runs` tables. It is
* the server-side backing store for the `ctx.jobs` SDK surface exposed to
* plugin workers.
*
* ## Responsibilities
*
* 1. **Sync job declarations** — When a plugin is installed or started, the
* host calls `syncJobDeclarations()` to upsert the manifest's declared jobs
* into the `plugin_jobs` table. Jobs removed from the manifest are marked
* `paused` (not deleted) to preserve history.
*
* 2. **Job CRUD** — List, get, pause, and resume jobs for a given plugin.
*
* 3. **Run lifecycle** — Create job run records, update their status, and
* record results (duration, errors, logs).
*
* 4. **Next-run calculation** — After a run completes the host should call
* `updateNextRunAt()` with the next cron tick so the scheduler knows when
* to fire next.
*
* The capability check (`jobs.schedule`) is enforced upstream by the host
* client factory and manifest validator — this store trusts that the caller
* has already been authorised.
*
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` / `plugin_job_runs` tables
*/
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { plugins, pluginJobs, pluginJobRuns } from "@paperclipai/db";
import type {
PluginJobDeclaration,
PluginJobRunStatus,
PluginJobRunTrigger,
PluginJobRecord,
} from "@paperclipai/shared";
import { notFound } from "../errors.js";
/**
* The statuses used for job *definitions* in the `plugin_jobs` table.
* Aliased from `PluginJobRecord` to keep the store API aligned with
* the domain type (`"active" | "paused" | "failed"`).
*/
type JobDefinitionStatus = PluginJobRecord["status"];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Input for creating a job run record.
*/
export interface CreateJobRunInput {
/** FK to the plugin_jobs row. */
jobId: string;
/** FK to the plugins row. */
pluginId: string;
/** What triggered this run. */
trigger: PluginJobRunTrigger;
}
/**
* Input for completing (or failing) a job run.
*/
export interface CompleteJobRunInput {
/** Final run status. */
status: PluginJobRunStatus;
/** Error message if the run failed. */
error?: string | null;
/** Run duration in milliseconds. */
durationMs?: number | null;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/**
* Create a PluginJobStore backed by the given Drizzle database instance.
*
* @example
* ```ts
* const jobStore = pluginJobStore(db);
*
* // On plugin install/start — sync declared jobs into the DB
* await jobStore.syncJobDeclarations(pluginId, manifest.jobs ?? []);
*
* // Before dispatching a runJob RPC — create a run record
* const run = await jobStore.createRun({ jobId, pluginId, trigger: "schedule" });
*
* // After the RPC completes — record the result
* await jobStore.completeRun(run.id, {
* status: "succeeded",
* durationMs: Date.now() - startedAt,
* });
* ```
*/
export function pluginJobStore(db: Db) {
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
async function assertPluginExists(pluginId: string): Promise<void> {
const rows = await db
.select({ id: plugins.id })
.from(plugins)
.where(eq(plugins.id, pluginId));
if (rows.length === 0) {
throw notFound(`Plugin not found: ${pluginId}`);
}
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
return {
// =====================================================================
// Job declarations (plugin_jobs)
// =====================================================================
/**
* Sync declared jobs from a plugin manifest into the `plugin_jobs` table.
*
* This is called at plugin install and on each worker startup so the DB
* always reflects the manifest's declared jobs:
*
* - **New jobs** are inserted with status `active`.
* - **Existing jobs** have their `schedule` updated if it changed.
* - **Removed jobs** (present in DB but absent from the manifest) are
* set to `paused` so their history is preserved.
*
* The unique constraint `(pluginId, jobKey)` is used for conflict
* resolution.
*
* @param pluginId - UUID of the owning plugin
* @param declarations - Job declarations from the plugin manifest
*/
async syncJobDeclarations(
pluginId: string,
declarations: PluginJobDeclaration[],
): Promise<void> {
await assertPluginExists(pluginId);
// Fetch existing jobs for this plugin
const existingJobs = await db
.select()
.from(pluginJobs)
.where(eq(pluginJobs.pluginId, pluginId));
const existingByKey = new Map(
existingJobs.map((j) => [j.jobKey, j]),
);
const declaredKeys = new Set<string>();
// Upsert each declared job
for (const decl of declarations) {
declaredKeys.add(decl.jobKey);
const existing = existingByKey.get(decl.jobKey);
const schedule = decl.schedule ?? "";
if (existing) {
// Update schedule if it changed; re-activate if it was paused
const updates: Record<string, unknown> = {
updatedAt: new Date(),
};
if (existing.schedule !== schedule) {
updates.schedule = schedule;
}
if (existing.status === "paused") {
updates.status = "active";
}
await db
.update(pluginJobs)
.set(updates)
.where(eq(pluginJobs.id, existing.id));
} else {
// Insert new job
await db.insert(pluginJobs).values({
pluginId,
jobKey: decl.jobKey,
schedule,
status: "active",
});
}
}
// Pause jobs that are no longer declared in the manifest
for (const existing of existingJobs) {
if (!declaredKeys.has(existing.jobKey) && existing.status !== "paused") {
await db
.update(pluginJobs)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(pluginJobs.id, existing.id));
}
}
},
/**
* List all jobs for a plugin, optionally filtered by status.
*
* @param pluginId - UUID of the owning plugin
* @param status - Optional status filter
*/
async listJobs(
pluginId: string,
status?: JobDefinitionStatus,
): Promise<(typeof pluginJobs.$inferSelect)[]> {
const conditions = [eq(pluginJobs.pluginId, pluginId)];
if (status) {
conditions.push(eq(pluginJobs.status, status));
}
return db
.select()
.from(pluginJobs)
.where(and(...conditions));
},
/**
* Get a single job by its composite key `(pluginId, jobKey)`.
*
* @param pluginId - UUID of the owning plugin
* @param jobKey - Stable job identifier from the manifest
* @returns The job row, or `null` if not found
*/
async getJobByKey(
pluginId: string,
jobKey: string,
): Promise<(typeof pluginJobs.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobs)
.where(
and(
eq(pluginJobs.pluginId, pluginId),
eq(pluginJobs.jobKey, jobKey),
),
);
return rows[0] ?? null;
},
/**
* Get a single job by its primary key (UUID).
*
* @param jobId - UUID of the job row
* @returns The job row, or `null` if not found
*/
async getJobById(
jobId: string,
): Promise<(typeof pluginJobs.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobs)
.where(eq(pluginJobs.id, jobId));
return rows[0] ?? null;
},
/**
* Fetch a single job by ID, scoped to a specific plugin.
*
* Returns `null` if the job does not exist or does not belong to the
* given plugin — callers should treat both cases as "not found".
*/
async getJobByIdForPlugin(
pluginId: string,
jobId: string,
): Promise<(typeof pluginJobs.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobs)
.where(and(eq(pluginJobs.id, jobId), eq(pluginJobs.pluginId, pluginId)));
return rows[0] ?? null;
},
/**
* Update a job's status.
*
* @param jobId - UUID of the job row
* @param status - New status
*/
async updateJobStatus(
jobId: string,
status: JobDefinitionStatus,
): Promise<void> {
await db
.update(pluginJobs)
.set({ status, updatedAt: new Date() })
.where(eq(pluginJobs.id, jobId));
},
/**
* Update the `lastRunAt` and `nextRunAt` timestamps on a job.
*
* Called by the scheduler after a run completes to advance the
* scheduling pointer.
*
* @param jobId - UUID of the job row
* @param lastRunAt - When the last run started
* @param nextRunAt - When the next run should fire
*/
async updateRunTimestamps(
jobId: string,
lastRunAt: Date,
nextRunAt: Date | null,
): Promise<void> {
await db
.update(pluginJobs)
.set({
lastRunAt,
nextRunAt,
updatedAt: new Date(),
})
.where(eq(pluginJobs.id, jobId));
},
/**
* Delete all jobs (and cascaded runs) owned by a plugin.
*
* Called during plugin uninstall when `removeData = true`.
*
* @param pluginId - UUID of the owning plugin
*/
async deleteAllJobs(pluginId: string): Promise<void> {
await db
.delete(pluginJobs)
.where(eq(pluginJobs.pluginId, pluginId));
},
// =====================================================================
// Job runs (plugin_job_runs)
// =====================================================================
/**
* Create a new job run record with status `queued`.
*
* The caller should create the run record *before* dispatching the
* `runJob` RPC to the worker, then update it to `running` once the
* worker begins execution.
*
* @param input - Job run input (jobId, pluginId, trigger)
* @returns The newly created run row
*/
async createRun(
input: CreateJobRunInput,
): Promise<typeof pluginJobRuns.$inferSelect> {
const rows = await db
.insert(pluginJobRuns)
.values({
jobId: input.jobId,
pluginId: input.pluginId,
trigger: input.trigger,
status: "queued",
})
.returning();
return rows[0]!;
},
/**
* Mark a run as `running` and set its `startedAt` timestamp.
*
* @param runId - UUID of the run row
*/
async markRunning(runId: string): Promise<void> {
await db
.update(pluginJobRuns)
.set({
status: "running" as PluginJobRunStatus,
startedAt: new Date(),
})
.where(eq(pluginJobRuns.id, runId));
},
/**
* Complete a run — set its final status, error, duration, and
* `finishedAt` timestamp.
*
* @param runId - UUID of the run row
* @param input - Completion details
*/
async completeRun(
runId: string,
input: CompleteJobRunInput,
): Promise<void> {
await db
.update(pluginJobRuns)
.set({
status: input.status,
error: input.error ?? null,
durationMs: input.durationMs ?? null,
finishedAt: new Date(),
})
.where(eq(pluginJobRuns.id, runId));
},
/**
* Get a run by its primary key.
*
* @param runId - UUID of the run row
* @returns The run row, or `null` if not found
*/
async getRunById(
runId: string,
): Promise<(typeof pluginJobRuns.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobRuns)
.where(eq(pluginJobRuns.id, runId));
return rows[0] ?? null;
},
/**
* List runs for a specific job, ordered by creation time descending.
*
* @param jobId - UUID of the job
* @param limit - Maximum number of rows to return (default: 50)
* @param companyIds - Optional tenant scope. `null`/omitted keeps the
* instance-wide view (used by instance administrators); an array limits
* rows to those companies; an empty array returns no rows.
*/
async listRunsByJob(
jobId: string,
limit = 50,
companyIds: string[] | null = null,
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
if (companyIds && companyIds.length === 0) {
return [];
}
const conditions = [eq(pluginJobRuns.jobId, jobId)];
if (companyIds) {
conditions.push(inArray(pluginJobRuns.companyId, companyIds));
}
return db
.select()
.from(pluginJobRuns)
.where(and(...conditions))
.orderBy(desc(pluginJobRuns.createdAt))
.limit(limit);
},
/**
* List runs for a plugin, optionally filtered by status.
*
* @param pluginId - UUID of the owning plugin
* @param status - Optional status filter
* @param limit - Maximum number of rows to return (default: 50)
* @param companyIds - Optional tenant scope. `null`/omitted keeps the
* instance-wide view (used by instance administrators); an array limits
* rows to those companies; an empty array returns no rows.
*/
async listRunsByPlugin(
pluginId: string,
status?: PluginJobRunStatus,
limit = 50,
companyIds: string[] | null = null,
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
if (companyIds && companyIds.length === 0) {
return [];
}
const conditions = [eq(pluginJobRuns.pluginId, pluginId)];
if (status) {
conditions.push(eq(pluginJobRuns.status, status));
}
if (companyIds) {
conditions.push(inArray(pluginJobRuns.companyId, companyIds));
}
return db
.select()
.from(pluginJobRuns)
.where(and(...conditions))
.orderBy(desc(pluginJobRuns.createdAt))
.limit(limit);
},
};
}
/** Type alias for the return value of `pluginJobStore()`. */
export type PluginJobStore = ReturnType<typeof pluginJobStore>;