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>
This commit is contained in:
committed by
GitHub
parent
a5b3cc98b0
commit
05bcd3ce84
@@ -0,0 +1,14 @@
|
||||
DROP INDEX "plugin_entities_external_idx";--> statement-breakpoint
|
||||
ALTER TABLE "plugin_entities" ADD COLUMN "company_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD COLUMN "company_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_logs" ADD COLUMN "company_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_webhook_deliveries" ADD COLUMN "company_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_company_idx" ON "plugin_entities" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_company_idx" ON "plugin_job_runs" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_company_idx" ON "plugin_logs" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_company_idx" ON "plugin_webhook_deliveries" USING btree ("company_id");--> statement-breakpoint
|
||||
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_external_idx" UNIQUE NULLS NOT DISTINCT("company_id","plugin_id","entity_type","external_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -708,6 +708,13 @@
|
||||
"when": 1781480000000,
|
||||
"tag": "0100_skill_install_count_backfill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 101,
|
||||
"version": "7",
|
||||
"when": 1781490000000,
|
||||
"tag": "0101_plugin_company_id_tenant_isolation",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
|
||||
@@ -31,6 +32,8 @@ export const pluginEntities = pgTable(
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Company scope — NULL for instance-level entities. */
|
||||
companyId: uuid("company_id").references(() => companies.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
|
||||
@@ -43,12 +46,25 @@ export const pluginEntities = pgTable(
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
|
||||
companyIdx: index("plugin_entities_company_idx").on(table.companyId),
|
||||
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
|
||||
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
|
||||
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
|
||||
table.pluginId,
|
||||
table.entityType,
|
||||
table.externalId,
|
||||
),
|
||||
/**
|
||||
* Per-tenant uniqueness on (companyId, pluginId, entityType, externalId).
|
||||
* `.nullsNotDistinct()` is required because companyId is nullable for
|
||||
* instance-scope entities (cron jobs, public webhooks): without it,
|
||||
* postgres treats two NULL company_ids as distinct and a tuple like
|
||||
* `(NULL, pluginId, entityType, externalId)` can be inserted multiple
|
||||
* times, losing the dedup guarantee. Same pattern as plugin_state.ts.
|
||||
* Requires PostgreSQL 15+.
|
||||
*/
|
||||
externalIdx: unique("plugin_entities_external_idx")
|
||||
.on(
|
||||
table.companyId,
|
||||
table.pluginId,
|
||||
table.entityType,
|
||||
table.externalId,
|
||||
)
|
||||
.nullsNotDistinct(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||
|
||||
@@ -80,6 +81,8 @@ export const pluginJobRuns = pgTable(
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Company scope — NULL for instance-level jobs. */
|
||||
companyId: uuid("company_id").references(() => companies.id, { onDelete: "cascade" }),
|
||||
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||
/** Current lifecycle state of this run. */
|
||||
@@ -97,6 +100,7 @@ export const pluginJobRuns = pgTable(
|
||||
(table) => ({
|
||||
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
|
||||
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
|
||||
companyIdx: index("plugin_job_runs_company_idx").on(table.companyId),
|
||||
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,8 @@ export const pluginLogs = pgTable(
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Company scope — NULL for instance-level logs. */
|
||||
companyId: uuid("company_id").references(() => companies.id, { onDelete: "cascade" }),
|
||||
level: text("level").notNull().default("info"),
|
||||
message: text("message").notNull(),
|
||||
meta: jsonb("meta").$type<Record<string, unknown>>(),
|
||||
@@ -38,6 +41,7 @@ export const pluginLogs = pgTable(
|
||||
table.pluginId,
|
||||
table.createdAt,
|
||||
),
|
||||
companyIdx: index("plugin_logs_company_idx").on(table.companyId),
|
||||
levelIdx: index("plugin_logs_level_idx").on(table.level),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
|
||||
|
||||
@@ -39,6 +40,8 @@ export const pluginWebhookDeliveries = pgTable(
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Company scope — NULL for instance-level webhook deliveries. */
|
||||
companyId: uuid("company_id").references(() => companies.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
|
||||
webhookKey: text("webhook_key").notNull(),
|
||||
/** Optional de-duplication ID provided by the external system. */
|
||||
@@ -59,6 +62,7 @@ export const pluginWebhookDeliveries = pgTable(
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
|
||||
companyIdx: index("plugin_webhook_deliveries_company_idx").on(table.companyId),
|
||||
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
|
||||
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
|
||||
}),
|
||||
|
||||
@@ -834,7 +834,13 @@ export interface WorkerToHostMethods {
|
||||
|
||||
// Metrics
|
||||
"metrics.write": [
|
||||
params: { name: string; value: number; tags?: Record<string, string> },
|
||||
params: {
|
||||
name: string;
|
||||
value: number;
|
||||
tags?: Record<string, string>;
|
||||
/** Owning tenant for `plugin_logs.company_id` (cascade-delete scope). `null`/omitted = instance-scope. */
|
||||
companyId?: string | null;
|
||||
},
|
||||
result: void,
|
||||
];
|
||||
|
||||
@@ -846,7 +852,13 @@ export interface WorkerToHostMethods {
|
||||
|
||||
// Logger
|
||||
"log": [
|
||||
params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record<string, unknown> },
|
||||
params: {
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
/** Owning tenant for `plugin_logs.company_id` (cascade-delete scope). `null`/omitted = instance-scope. */
|
||||
companyId?: string | null;
|
||||
},
|
||||
result: void,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -404,6 +404,14 @@ function sanitiseMeta(meta: Record<string, unknown> | null | undefined): Record<
|
||||
interface BufferedLogEntry {
|
||||
db: Db;
|
||||
pluginId: string;
|
||||
/**
|
||||
* Owning tenant for `plugin_logs.company_id` — populated when the caller
|
||||
* attributes the log/metric to a specific company so the row participates
|
||||
* in the `ON DELETE CASCADE` from `companies`. `null` means instance-scope
|
||||
* (cron jobs / public webhooks without a tenant); those rows survive
|
||||
* company deletes but are still attributable.
|
||||
*/
|
||||
companyId: string | null;
|
||||
level: string;
|
||||
message: string;
|
||||
meta: Record<string, unknown> | null;
|
||||
@@ -436,6 +444,7 @@ export async function flushPluginLogBuffer(): Promise<void> {
|
||||
for (const [dbInstance, group] of byDb) {
|
||||
const values = group.map((e) => ({
|
||||
pluginId: e.pluginId,
|
||||
companyId: e.companyId,
|
||||
level: e.level,
|
||||
message: e.message,
|
||||
meta: e.meta,
|
||||
@@ -1262,6 +1271,7 @@ export function buildHostServices(
|
||||
_logBuffer.push({
|
||||
db,
|
||||
pluginId,
|
||||
companyId: params.companyId ?? null,
|
||||
level: "metric",
|
||||
message: safeName,
|
||||
meta: sanitiseMeta({ value: params.value, tags: params.tags ?? null }),
|
||||
@@ -1310,6 +1320,7 @@ export function buildHostServices(
|
||||
_logBuffer.push({
|
||||
db,
|
||||
pluginId,
|
||||
companyId: params.companyId ?? null,
|
||||
level: level ?? "info",
|
||||
message: safeMessage,
|
||||
meta: safeMeta,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, eq, ne, sql, and } from "drizzle-orm";
|
||||
import { asc, eq, isNull, ne, sql, and } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
plugins,
|
||||
@@ -473,27 +473,41 @@ export function pluginRegistryService(db: Db) {
|
||||
/**
|
||||
* Look up a plugin-owned entity mapping by its external identifier.
|
||||
*
|
||||
* Scope matches `plugin_entities_external_idx` (NULLS NOT DISTINCT):
|
||||
* pass the owning `companyId` (or `null` for instance-scope) to retrieve
|
||||
* the row that belongs to that tenant. Two companies can share the same
|
||||
* `(pluginId, entityType, externalId)` tuple — omitting `companyId` would
|
||||
* return the first matched row regardless of tenant, which is unsafe.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param entityType - The type of entity (e.g., 'project', 'issue').
|
||||
* @param externalId - The identifier in the external system.
|
||||
* @param companyId - Tenant scope; `null` for instance-scope entities.
|
||||
* @returns The matching `PluginEntityRecord` or null.
|
||||
*/
|
||||
getEntityByExternalId: (
|
||||
pluginId: string,
|
||||
entityType: string,
|
||||
externalId: string,
|
||||
) =>
|
||||
db
|
||||
companyId: string | null,
|
||||
) => {
|
||||
const companyIdPredicate =
|
||||
companyId == null
|
||||
? isNull(pluginEntities.companyId)
|
||||
: eq(pluginEntities.companyId, companyId);
|
||||
return db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
companyIdPredicate,
|
||||
eq(pluginEntities.pluginId, pluginId),
|
||||
eq(pluginEntities.entityType, entityType),
|
||||
eq(pluginEntities.externalId, externalId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create or update a persistent mapping between a Paperclip object and an
|
||||
@@ -509,11 +523,22 @@ export function pluginRegistryService(db: Db) {
|
||||
) => {
|
||||
// Drizzle doesn't support pg-specific onConflictDoUpdate easily in the insert() call
|
||||
// with complex where clauses, so we do it manually.
|
||||
// Match the per-tenant uniqueness of `plugin_entities_external_idx`
|
||||
// (companyId, pluginId, entityType, externalId) with NULLS NOT DISTINCT
|
||||
// semantics: two companies (and instance-scope NULLs across each other)
|
||||
// may share the same (pluginId, entityType, externalId) tuple, so the
|
||||
// lookup MUST scope by companyId — `isNull` for instance-scope, `eq`
|
||||
// otherwise — to avoid returning and overwriting another tenant's row.
|
||||
const companyIdPredicate =
|
||||
input.companyId == null
|
||||
? isNull(pluginEntities.companyId)
|
||||
: eq(pluginEntities.companyId, input.companyId);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
companyIdPredicate,
|
||||
eq(pluginEntities.pluginId, pluginId),
|
||||
eq(pluginEntities.entityType, input.entityType),
|
||||
eq(pluginEntities.externalId, input.externalId ?? ""),
|
||||
@@ -633,21 +658,29 @@ export function pluginRegistryService(db: Db) {
|
||||
/**
|
||||
* Record the start of a specific job execution.
|
||||
*
|
||||
* Pass the owning `companyId` so `plugin_job_runs.company_id` is populated
|
||||
* and the row participates in the `ON DELETE CASCADE` from `companies`.
|
||||
* `null` is the explicit instance-scope marker (cron jobs without a tenant);
|
||||
* those rows survive company deletes but are still attributable.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobId - The UUID of the parent job record.
|
||||
* @param trigger - What triggered this run (e.g., 'schedule', 'manual').
|
||||
* @param companyId - Tenant scope; `null` for instance-scope runs.
|
||||
* @returns The newly created `PluginJobRunRecord` in 'pending' status.
|
||||
*/
|
||||
createJobRun: async (
|
||||
pluginId: string,
|
||||
jobId: string,
|
||||
trigger: PluginJobRunTrigger,
|
||||
companyId: string | null,
|
||||
) => {
|
||||
return db
|
||||
.insert(pluginJobRuns)
|
||||
.values({
|
||||
pluginId,
|
||||
jobId,
|
||||
companyId,
|
||||
trigger,
|
||||
status: "pending",
|
||||
})
|
||||
@@ -686,14 +719,22 @@ export function pluginRegistryService(db: Db) {
|
||||
/**
|
||||
* Create a record for an incoming webhook delivery.
|
||||
*
|
||||
* Pass the owning `companyId` so `plugin_webhook_deliveries.company_id` is
|
||||
* populated and the row participates in the `ON DELETE CASCADE` from
|
||||
* `companies`. `null` is the explicit instance-scope marker (public
|
||||
* webhooks without a tenant); those rows survive company deletes but are
|
||||
* still attributable.
|
||||
*
|
||||
* @param pluginId - The UUID of the receiving plugin.
|
||||
* @param webhookKey - The endpoint key defined in the manifest.
|
||||
* @param companyId - Tenant scope; `null` for instance-scope deliveries.
|
||||
* @param input - The payload, headers, and optional external ID.
|
||||
* @returns The newly created `PluginWebhookDeliveryRecord` in 'pending' status.
|
||||
*/
|
||||
createWebhookDelivery: async (
|
||||
pluginId: string,
|
||||
webhookKey: string,
|
||||
companyId: string | null,
|
||||
input: {
|
||||
externalId?: string;
|
||||
payload: Record<string, unknown>;
|
||||
@@ -705,6 +746,7 @@ export function pluginRegistryService(db: Db) {
|
||||
.values({
|
||||
pluginId,
|
||||
webhookKey,
|
||||
companyId,
|
||||
externalId: input.externalId,
|
||||
payload: input.payload,
|
||||
headers: input.headers ?? {},
|
||||
|
||||
Reference in New Issue
Block a user