Files
paperclip/server/src/__tests__/plugin-tenant-isolation.test.ts
T
Jannes Stubbemann 05bcd3ce84 feat(security): plugin tables get company_id FK for tenant isolation (#5865)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin subsystem persists state into four tables
(`plugin_entities`, `plugin_job_runs`, `plugin_logs`,
`plugin_webhook_deliveries`) and those rows currently have no notion of
an owning tenant — so company-deletion doesn't cascade plugin state, and
operators have no way to query "what does this plugin own for company
X?"
> - The fix is one thin slice of tenant-isolation hygiene that doesn't
change any external API: add a nullable `company_id` FK with `ON DELETE
CASCADE` to the four plugin tables, index it, and scope the
`plugin_entities` external-id uniqueness per-tenant
> - The benefit is plugin-row tenant attribution + cascade cleanup, with
zero impact on single-tenant local-first deploys (`NULL` continues to
mean instance-scope)

> **Rebase note (scope narrowed):** This PR originally also hardened the
schedulers (`heartbeat.tickTimers` / `resumeQueuedRuns` /
`enqueueWakeup` and `routines.tickScheduledTriggers`) to skip archived
companies. That half has since landed on `master` via #7478 (`93206f73`,
"Stop archived companies from waking agents") with a stricter
implementation (`status != 'active'` plus a skipped-request audit row).
On rebase those changes were dropped as redundant — `heartbeat.ts` and
`routines.ts` are now identical to `master`, and the scheduler-specific
tests were removed. **This PR is now DB-only.**

## Linked Issues or Issue Description

No existing issue covers this directly — problem described in-PR:

- Four plugin tables (`plugin_entities`, `plugin_job_runs`,
`plugin_logs`, `plugin_webhook_deliveries`) persist rows with no notion
of an owning tenant.
- Company deletion therefore does not cascade plugin state, and
operators have no way to query "what does this plugin own for company
X?"
- One thin slice of tenant-isolation hygiene fixes this without changing
any external API: a nullable `company_id` FK with `ON DELETE CASCADE`,
an index, and per-tenant scoping of the `plugin_entities` external-id
uniqueness.
- Part of the multi-tenant hardening initiative alongside #3967
(cross-tenant 404 oracle) and #5864 (per-company JWT keys).

## What Changed

**Schema (`packages/db/src/schema/plugin_*.ts`):**
- Nullable `companyId` FK with `onDelete: "cascade"` added to
`plugin_entities`, `plugin_job_runs`, `plugin_logs`,
`plugin_webhook_deliveries`.
- A btree index on each new `company_id` column (`<table>_company_idx`).
- `plugin_entities_external_idx` rescoped from `(plugin_id, entity_type,
external_id)` to `(company_id, plugin_id, entity_type, external_id)` and
switched to `UNIQUE … NULLS NOT DISTINCT` so instance-scope rows
(`company_id IS NULL`) keep their dedup guarantee while tenants get
their own namespace.

**Migration:**
- `0095_plugin_company_id_tenant_isolation.sql` — 14 statements: 4
column adds + 4 FK constraints (`ON DELETE CASCADE`) + 4 indexes +
drop/recreate of the external-id unique constraint.
- Journal entry + regenerated `0095_snapshot.json`.

**Tests:**
- `server/src/__tests__/plugin-tenant-isolation.test.ts` — `NULL`
preserves backward compat; `CASCADE` on company delete across all four
tables; per-tenant external-id namespacing; NULL-NULL collision rejected
(`NULLS NOT DISTINCT`).

## Verification

- `pnpm --filter @paperclipai/db run check:migrations` — pass.
- `pnpm --filter @paperclipai/db typecheck` (`tsc`) — pass.
- `vitest run plugin-tenant-isolation` — **4/4 pass** (embedded Postgres
applies `0095` and exercises cascade + NULLS NOT DISTINCT).

## Notes

- **Clean snapshot, no drift.** The earlier revision of this PR shipped
a ~17.6k-line meta snapshot that was almost entirely pre-existing drift.
On rebase the migration was renumbered (the old `0090_brainy_darkhawk`
collided with `master`'s `0090_resource_memberships … 0094`) and
regenerated from the current `master` baseline via `drizzle-kit
generate`. The result is a 14-statement migration containing **only**
the plugin-table changes — no unrelated drift.
- **Backward-compatible.** `NULL company_id` continues to mean
instance-scope (cron jobs, public webhooks). No new env vars, no API
surface change. Single-tenant local-first deploys unaffected.

## Risks

- **Migration is additive and nullable** — `0095` adds nullable
`company_id` columns, FK constraints, and indexes; existing rows stay
valid (`NULL` keeps meaning instance-scope) and no backfill is required.
- **`ON DELETE CASCADE` is a behavioral change**: deleting a company now
removes its plugin rows across all four tables. Intended (it is the
point of the PR), but operators relying on plugin rows surviving company
deletion would be affected. Covered by the cascade tests.
- **Uniqueness semantics change on `plugin_entities`**: the external-id
constraint is rescoped per-tenant and switched to `UNIQUE … NULLS NOT
DISTINCT`, so two instance-scope rows (`company_id IS NULL`) with the
same external id are now rejected instead of coexisting. Covered by the
NULL-NULL collision test.
- **No API surface change, no new env vars.** Single-tenant local-first
deploys unaffected.

(Section added retroactively to match the PR template; distilled from
the What Changed / Notes sections above.)

## Model Used

Same authoring setup as #5864 (same series, same day): Claude Opus 4.7
(1M context), extended thinking mode. (Section added retroactively.)

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Tests run locally and pass (plugin-tenant-isolation 4/4)
- [x] `check:migrations` + db typecheck pass
- [x] No UI changes
- [x] Migration carries only the intended changes (no snapshot drift)
- [x] Scheduler half dropped as superseded by #7478

Part of the multi-tenant hardening initiative — see also #3967
(cross-tenant 404 oracle) and #5864 (per-company JWT keys).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Devin Foley <devin@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-12 10:17:19 -07:00

529 lines
18 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
createDb,
pluginEntities,
pluginJobs,
pluginJobRuns,
pluginLogs,
pluginWebhookDeliveries,
plugins,
} from "@paperclipai/db";
import { buildHostServices, flushPluginLogBuffer } from "../services/plugin-host-services.js";
import { pluginRegistryService } from "../services/plugin-registry.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
function createEventBusStub() {
return {
forPlugin() {
return {
emit: vi.fn(),
subscribe: vi.fn(),
clear: vi.fn(),
};
},
} as any;
}
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping plugin tenant-isolation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-tenant-isolation-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(pluginEntities);
await db.delete(pluginJobRuns);
await db.delete(pluginJobs);
await db.delete(pluginLogs);
await db.delete(pluginWebhookDeliveries);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedPlugin() {
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.tenant-isolation-test",
packageName: "@paperclipai/plugin-tenant-isolation-test",
version: "0.0.1",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "paperclip.tenant-isolation-test",
apiVersion: 1,
version: "0.0.1",
displayName: "Tenant Isolation Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
},
status: "ready",
installOrder: 1,
});
return pluginId;
}
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `Tenant ${companyId.slice(0, 6)}`,
issuePrefix: issuePrefix(companyId),
});
return companyId;
}
it("allows NULL company_id on plugin_logs (instance-scope rows behave as before)", async () => {
const pluginId = await seedPlugin();
await db.insert(pluginLogs).values({
pluginId,
// companyId intentionally omitted — NULL means instance-scope.
level: "info",
message: "instance-scope log",
});
const rows = await db.select().from(pluginLogs).where(eq(pluginLogs.pluginId, pluginId));
expect(rows).toHaveLength(1);
expect(rows[0]?.companyId).toBeNull();
});
it("cascades plugin_logs / plugin_entities / plugin_job_runs / plugin_webhook_deliveries when the owning company is deleted", async () => {
const pluginId = await seedPlugin();
const companyA = await seedCompany();
const companyB = await seedCompany();
// Seed a job + run so we can verify plugin_job_runs cascades too.
const jobAId = randomUUID();
const jobBId = randomUUID();
await db.insert(pluginJobs).values([
{ id: jobAId, pluginId, jobKey: "cron-a", schedule: "* * * * *" },
{ id: jobBId, pluginId, jobKey: "cron-b", schedule: "* * * * *" },
]);
await db.insert(pluginLogs).values([
{ pluginId, companyId: companyA, level: "info", message: "A log" },
{ pluginId, companyId: companyB, level: "info", message: "B log" },
{ pluginId, level: "info", message: "instance log" },
]);
await db.insert(pluginEntities).values([
{
pluginId,
companyId: companyA,
entityType: "issue",
scopeKind: "company",
scopeId: companyA,
externalId: "ext-a",
},
{
pluginId,
companyId: companyB,
entityType: "issue",
scopeKind: "company",
scopeId: companyB,
externalId: "ext-b",
},
]);
await db.insert(pluginJobRuns).values([
{ jobId: jobAId, pluginId, companyId: companyA, trigger: "manual" },
{ jobId: jobBId, pluginId, companyId: companyB, trigger: "manual" },
{ jobId: jobAId, pluginId, trigger: "scheduled" },
]);
await db.insert(pluginWebhookDeliveries).values([
{ pluginId, companyId: companyA, webhookKey: "wh", payload: { who: "A" } },
{ pluginId, companyId: companyB, webhookKey: "wh", payload: { who: "B" } },
{ pluginId, webhookKey: "wh", payload: { who: "instance" } },
]);
// Delete company A — only A's rows should be reaped. B's and NULL-scope rows stay.
await db.delete(companies).where(eq(companies.id, companyA));
const logs = await db.select().from(pluginLogs);
expect(logs.map((r) => r.companyId).sort((a, b) => String(a).localeCompare(String(b)))).toEqual(
[companyB, null].sort((a, b) => String(a).localeCompare(String(b))),
);
const entities = await db.select().from(pluginEntities);
expect(entities).toHaveLength(1);
expect(entities[0]?.companyId).toBe(companyB);
const runs = await db.select().from(pluginJobRuns);
expect(runs.map((r) => r.companyId).sort((a, b) => String(a).localeCompare(String(b)))).toEqual(
[companyB, null].sort((a, b) => String(a).localeCompare(String(b))),
);
const deliveries = await db.select().from(pluginWebhookDeliveries);
expect(deliveries.map((r) => r.companyId).sort((a, b) => String(a).localeCompare(String(b)))).toEqual(
[companyB, null].sort((a, b) => String(a).localeCompare(String(b))),
);
});
it("plugin_entities unique index is scoped per company — two tenants can share (pluginId, entityType, externalId)", async () => {
const pluginId = await seedPlugin();
const companyA = await seedCompany();
const companyB = await seedCompany();
// Company A claims external id "ext-1".
await db.insert(pluginEntities).values({
pluginId,
companyId: companyA,
entityType: "page",
scopeKind: "company",
scopeId: companyA,
externalId: "ext-1",
});
// Company B uses the SAME (pluginId, entityType, externalId) — must succeed
// under the per-company unique index (would have collided under the old index).
await db.insert(pluginEntities).values({
pluginId,
companyId: companyB,
entityType: "page",
scopeKind: "company",
scopeId: companyB,
externalId: "ext-1",
});
const rows = await db.select().from(pluginEntities);
expect(rows).toHaveLength(2);
// Re-inserting the same (companyId, pluginId, entityType, externalId) tuple
// for company A must violate the unique constraint. Drizzle wraps the
// underlying pg error as "Failed query: ..." — inspect the cause to confirm
// it's the unique violation on our index (pg error code 23505).
const err = await db
.insert(pluginEntities)
.values({
pluginId,
companyId: companyA,
entityType: "page",
scopeKind: "company",
scopeId: companyA,
externalId: "ext-1",
})
.then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
// postgres error code 23505 = unique_violation, the constraint name is
// not always surfaced on .cause by the driver, but the code is sufficient
// to prove the unique index rejected the duplicate.
const cause = (err as { cause?: { code?: string } }).cause;
expect(cause?.code).toBe("23505");
});
it("pluginRegistryService.upsertEntity scopes its lookup by companyId — never overwrites another tenant's row", async () => {
const pluginId = await seedPlugin();
const companyA = await seedCompany();
const companyB = await seedCompany();
const registry = pluginRegistryService(db);
// Company A claims (issue, ext-shared) with title "A".
const createdA = await registry.upsertEntity(pluginId, {
companyId: companyA,
entityType: "issue",
scopeKind: "company",
scopeId: companyA,
externalId: "ext-shared",
title: "A",
status: "open",
data: {},
});
// Company B upserts the SAME (entityType, externalId) tuple under its own
// scope — must create a NEW row for B, NOT overwrite A.
const createdB = await registry.upsertEntity(pluginId, {
companyId: companyB,
entityType: "issue",
scopeKind: "company",
scopeId: companyB,
externalId: "ext-shared",
title: "B",
status: "open",
data: {},
});
expect(createdA?.id).toBeTruthy();
expect(createdB?.id).toBeTruthy();
expect(createdA?.id).not.toBe(createdB?.id);
// Company B updates its own row — A's row must remain untouched.
const updatedB = await registry.upsertEntity(pluginId, {
companyId: companyB,
entityType: "issue",
scopeKind: "company",
scopeId: companyB,
externalId: "ext-shared",
title: "B-updated",
status: "closed",
data: {},
});
expect(updatedB?.id).toBe(createdB?.id);
expect(updatedB?.title).toBe("B-updated");
const rows = await db.select().from(pluginEntities);
expect(rows).toHaveLength(2);
const rowA = rows.find((r) => r.companyId === companyA);
const rowB = rows.find((r) => r.companyId === companyB);
expect(rowA?.title).toBe("A");
expect(rowA?.status).toBe("open");
expect(rowB?.title).toBe("B-updated");
expect(rowB?.status).toBe("closed");
// Instance-scope upsert (companyId = NULL) on the same tuple must also
// create its own row, not collide with A or B.
const createdInstance = await registry.upsertEntity(pluginId, {
companyId: null,
entityType: "issue",
scopeKind: "instance",
scopeId: null,
externalId: "ext-shared",
title: "instance",
status: "open",
data: {},
});
expect(createdInstance?.id).toBeTruthy();
expect(createdInstance?.id).not.toBe(createdA?.id);
expect(createdInstance?.id).not.toBe(createdB?.id);
const allRows = await db.select().from(pluginEntities);
expect(allRows).toHaveLength(3);
});
it("pluginRegistryService.getEntityByExternalId scopes by companyId — never returns another tenant's row", async () => {
const pluginId = await seedPlugin();
const companyA = await seedCompany();
const companyB = await seedCompany();
const registry = pluginRegistryService(db);
await registry.upsertEntity(pluginId, {
companyId: companyA,
entityType: "issue",
scopeKind: "company",
scopeId: companyA,
externalId: "ext-shared",
title: "A",
status: "open",
data: {},
});
await registry.upsertEntity(pluginId, {
companyId: companyB,
entityType: "issue",
scopeKind: "company",
scopeId: companyB,
externalId: "ext-shared",
title: "B",
status: "open",
data: {},
});
await registry.upsertEntity(pluginId, {
companyId: null,
entityType: "issue",
scopeKind: "instance",
scopeId: null,
externalId: "ext-shared",
title: "instance",
status: "open",
data: {},
});
const fromA = await registry.getEntityByExternalId(pluginId, "issue", "ext-shared", companyA);
expect(fromA?.companyId).toBe(companyA);
expect(fromA?.title).toBe("A");
const fromB = await registry.getEntityByExternalId(pluginId, "issue", "ext-shared", companyB);
expect(fromB?.companyId).toBe(companyB);
expect(fromB?.title).toBe("B");
const fromInstance = await registry.getEntityByExternalId(pluginId, "issue", "ext-shared", null);
expect(fromInstance?.companyId).toBeNull();
expect(fromInstance?.title).toBe("instance");
// Unknown tenant returns null, not another tenant's row.
const unknown = await registry.getEntityByExternalId(
pluginId,
"issue",
"ext-shared",
randomUUID(),
);
expect(unknown).toBeNull();
});
it("pluginRegistryService.createJobRun + createWebhookDelivery persist companyId so cascade delete reaps them", async () => {
const pluginId = await seedPlugin();
const companyA = await seedCompany();
const companyB = await seedCompany();
const registry = pluginRegistryService(db);
const jobId = randomUUID();
await db.insert(pluginJobs).values({
id: jobId,
pluginId,
jobKey: "test-job",
schedule: "* * * * *",
});
const runA = await registry.createJobRun(pluginId, jobId, "manual", companyA);
const runB = await registry.createJobRun(pluginId, jobId, "manual", companyB);
const runInstance = await registry.createJobRun(pluginId, jobId, "scheduled", null);
expect(runA?.companyId).toBe(companyA);
expect(runB?.companyId).toBe(companyB);
expect(runInstance?.companyId).toBeNull();
const whA = await registry.createWebhookDelivery(pluginId, "wh", companyA, {
payload: { who: "A" },
});
const whB = await registry.createWebhookDelivery(pluginId, "wh", companyB, {
payload: { who: "B" },
});
const whInstance = await registry.createWebhookDelivery(pluginId, "wh", null, {
payload: { who: "instance" },
});
expect(whA?.companyId).toBe(companyA);
expect(whB?.companyId).toBe(companyB);
expect(whInstance?.companyId).toBeNull();
// Cascade: deleting company A reaps A's rows; B's and instance-scope rows stay.
await db.delete(companies).where(eq(companies.id, companyA));
const runs = await db.select().from(pluginJobRuns);
expect(runs.map((r) => r.companyId).sort((a, b) => String(a).localeCompare(String(b)))).toEqual(
[companyB, null].sort((a, b) => String(a).localeCompare(String(b))),
);
const deliveries = await db.select().from(pluginWebhookDeliveries);
expect(
deliveries.map((r) => r.companyId).sort((a, b) => String(a).localeCompare(String(b))),
).toEqual([companyB, null].sort((a, b) => String(a).localeCompare(String(b))));
});
it("buildHostServices.logger.log + flushPluginLogBuffer persist companyId so cascade delete reaps log rows", async () => {
const pluginId = await seedPlugin();
const companyA = await seedCompany();
const companyB = await seedCompany();
// Flush any leftovers from prior tests (the buffer is module-scoped).
await flushPluginLogBuffer();
await db.delete(pluginLogs);
const services = buildHostServices(db, pluginId, "tenant-isolation-test", createEventBusStub());
try {
await services.logger.log({
level: "info",
message: "A log",
companyId: companyA,
});
await services.logger.log({
level: "warn",
message: "B log",
companyId: companyB,
});
await services.logger.log({
level: "info",
message: "instance log",
// companyId omitted — explicit instance-scope.
});
await services.logger.log({
level: "debug",
message: "explicit-null log",
companyId: null,
});
await flushPluginLogBuffer();
const rows = await db
.select()
.from(pluginLogs)
.where(eq(pluginLogs.pluginId, pluginId));
const byMessage = new Map(rows.map((r) => [r.message, r]));
expect(byMessage.get("A log")?.companyId).toBe(companyA);
expect(byMessage.get("B log")?.companyId).toBe(companyB);
expect(byMessage.get("instance log")?.companyId).toBeNull();
expect(byMessage.get("explicit-null log")?.companyId).toBeNull();
// Cascade: deleting company A reaps A's log row; B's + NULL rows remain.
await db.delete(companies).where(eq(companies.id, companyA));
const afterDelete = await db
.select()
.from(pluginLogs)
.where(eq(pluginLogs.pluginId, pluginId));
const messages = afterDelete.map((r) => r.message).sort();
expect(messages).toEqual(["B log", "explicit-null log", "instance log"]);
} finally {
services.dispose();
// Ensure no leftover entries leak into other tests.
await flushPluginLogBuffer();
}
});
it("plugin_entities unique index treats NULL companyId as equal (NULLS NOT DISTINCT) so instance-scope dedup holds", async () => {
const pluginId = await seedPlugin();
// First instance-scope entity (companyId = NULL) — succeeds.
await db.insert(pluginEntities).values({
pluginId,
companyId: null,
entityType: "cron",
scopeKind: "instance",
scopeId: null,
externalId: "global-cron-1",
});
// Second instance-scope row with the SAME (pluginId, entityType, externalId)
// must be rejected. Without `.nullsNotDistinct()`, postgres would treat the
// two NULL company_ids as distinct and silently allow the duplicate.
const err = await db
.insert(pluginEntities)
.values({
pluginId,
companyId: null,
entityType: "cron",
scopeKind: "instance",
scopeId: null,
externalId: "global-cron-1",
})
.then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect((err as { cause?: { code?: string } }).cause?.code).toBe("23505");
});
});