fix: audit plugin bridge config and job attribution

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 15:03:24 +00:00
1 parent aed4478c81
commit 9ec5052236
17 files changed
+748 -33

No files matched your search

@@ -0,0 +1,17 @@
ALTER TABLE "plugin_job_runs"
ADD COLUMN IF NOT EXISTS "triggered_by_actor_type" text;
ALTER TABLE "plugin_job_runs"
ADD COLUMN IF NOT EXISTS "triggered_by_actor_id" text;
ALTER TABLE "plugin_job_runs"
ADD COLUMN IF NOT EXISTS "triggered_by_agent_id" uuid REFERENCES "agents"("id");
ALTER TABLE "plugin_job_runs"
ADD COLUMN IF NOT EXISTS "triggered_by_user_id" text;
ALTER TABLE "plugin_job_runs"
ADD COLUMN IF NOT EXISTS "triggered_by_run_id" uuid REFERENCES "heartbeat_runs"("id");
ALTER TABLE "plugin_job_runs"
ADD COLUMN IF NOT EXISTS "responsible_user_id" text;
@@ -1338,6 +1338,13 @@
"when": 1784916886226,
"tag": "0192_task_watchdog_stop_snapshots",
"breakpoints": true
},
{
"idx": 193,
"version": "7",
"when": 1785164780000,
"tag": "0193_plugin_job_run_attribution",
"breakpoints": true
}
]
}
+14
View File
@@ -8,7 +8,9 @@ import {
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { plugins } from "./plugins.js";
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
@@ -85,6 +87,18 @@ export const pluginJobRuns = pgTable(
companyId: uuid("company_id").references(() => companies.id, { onDelete: "cascade" }),
/** What caused this run to start (`"scheduled"` or `"manual"`). */
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
/** Host-derived actor type that triggered this run, when not scheduler-owned. */
triggeredByActorType: text("triggered_by_actor_type"),
/** Host-derived actor id that triggered this run, when not scheduler-owned. */
triggeredByActorId: text("triggered_by_actor_id"),
/** Host-derived agent id that triggered this run, when applicable. */
triggeredByAgentId: uuid("triggered_by_agent_id").references(() => agents.id),
/** Host-derived board/user id that triggered this run, when applicable. */
triggeredByUserId: text("triggered_by_user_id"),
/** Host-derived heartbeat run id that triggered this run, when applicable. */
triggeredByRunId: uuid("triggered_by_run_id").references(() => heartbeatRuns.id),
/** Responsible user resolved by the host for this run, when known. */
responsibleUserId: text("responsible_user_id"),
/** Current lifecycle state of this run. */
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
/** Wall-clock duration in milliseconds. Null until the run finishes. */
+14
View File
@@ -871,8 +871,22 @@ export interface PluginJobRunRecord {
jobId: string;
/** FK to `plugins.id`. */
pluginId: string;
/** Company scope for company-owned runs; null for instance-scope rows. */
companyId: string | null;
/** What triggered this run. */
trigger: "schedule" | "manual" | "retry";
/** Host-derived actor type that triggered the run, when available. */
triggeredByActorType: "agent" | "user" | "system" | "plugin" | null;
/** Host-derived actor id that triggered the run, when available. */
triggeredByActorId: string | null;
/** Host-derived agent id that triggered the run, when applicable. */
triggeredByAgentId: string | null;
/** Host-derived board/user id that triggered the run, when applicable. */
triggeredByUserId: string | null;
/** Host-derived heartbeat run id that triggered the run, when applicable. */
triggeredByRunId: string | null;
/** Responsible user resolved by the host for this run, when known. */
responsibleUserId: string | null;
/** Current run status. */
status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
/** Run duration in milliseconds. */
@@ -231,4 +231,65 @@ describeEmbeddedPostgres("logActivity responsible-user stamping", () => {
expect(row?.responsibleUserId).toBe("key-user");
});
it("persists plugin audit run attribution from heartbeat responsible user", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const pluginId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
defaultResponsibleUserId: "default-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
responsibleUserId: "run-user",
});
await logActivity(db, activityInput({
companyId,
actorType: "agent",
actorId: agentId,
agentId,
runId,
action: "plugin.bridge.action_performed",
entityType: "plugin",
entityId: pluginId,
details: { pluginId, actionKey: "sync" },
}));
const row = await db
.select({
agentId: activityLog.agentId,
runId: activityLog.runId,
responsibleUserId: activityLog.responsibleUserId,
})
.from(activityLog)
.where(eq(activityLog.companyId, companyId))
.then((rows) => rows[0]);
expect(row).toMatchObject({
agentId,
runId,
responsibleUserId: "run-user",
});
});
});
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
getConfig: vi.fn(),
upsertConfig: vi.fn(),
getCompanySettings: vi.fn(),
upsertCompanySettings: vi.fn(),
@@ -23,6 +24,8 @@ const mockSecretService = vi.hoisted(() => ({
syncSecretRefsForTarget: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
@@ -31,9 +34,13 @@ vi.mock("../services/plugin-lifecycle.js", () => ({
pluginLifecycleManager: () => mockLifecycle,
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
}));
vi.mock("../services/activity-log.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/activity-log.js")>();
return {
...actual,
logActivity: mockLogActivity,
};
});
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
@@ -353,6 +360,67 @@ describe.sequential("plugin install and upgrade authz", () => {
});
}, 20_000);
it("audits plugin config reads and tests without raw config values", async () => {
readyPlugin();
mockRegistry.getConfig.mockResolvedValue({
id: "config-1",
pluginId,
companyId: companyA,
configJson: { apiToken: "stored-secret-value" },
});
const call = vi.fn().mockResolvedValue({ ok: true, warnings: ["reachable"] });
const { app } = await createApp(boardActor({ runId: runA }), {}, {
bridgeDeps: {
workerManager: { call, isRunning: vi.fn(() => true) },
},
});
const readRes = await request(app)
.get(`/api/plugins/${pluginId}/config?companyId=${companyA}`);
const testRes = await request(app)
.post(`/api/plugins/${pluginId}/config/test`)
.send({ companyId: companyA, configJson: { apiToken: "submitted-secret-value" } });
expect(readRes.status).toBe(200);
expect(testRes.status).toBe(200);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: companyA,
actorType: "user",
actorId: "user-1",
runId: runA,
action: "plugin.config.read",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
pluginKey: "paperclip.example",
companyId: companyA,
configPresent: true,
configKeyCount: 1,
}),
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: companyA,
actorType: "user",
actorId: "user-1",
runId: runA,
action: "plugin.config.tested",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
companyId: companyA,
supported: true,
valid: true,
warningCount: 1,
configKeyCount: 1,
}),
}));
const auditCalls = JSON.stringify(mockLogActivity.mock.calls);
expect(auditCalls).not.toContain("stored-secret-value");
expect(auditCalls).not.toContain("submitted-secret-value");
}, 20_000);
it("rejects plugin config saves that reference another company's secret before syncing bindings", async () => {
readyPlugin();
mockSecretService.getById.mockResolvedValue({ id: secretId, companyId: companyB, status: "active" });
@@ -715,6 +783,25 @@ describe.sequential("plugin tool and bridge authz", () => {
params: { view: "compact" },
renderEnvironment: null,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: companyA,
actorType: "user",
actorId: "user-1",
agentId: null,
runId: null,
action: "plugin.bridge.data_read",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
pluginKey: "paperclip.example",
companyId: companyA,
bridgeMethod: "getData",
dataKey: "health",
routeStyle: "url",
outcome: "success",
}),
}));
});
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
@@ -873,6 +960,25 @@ describe.sequential("plugin tool and bridge authz", () => {
},
renderEnvironment: null,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: companyA,
actorType: "agent",
actorId: agentA,
agentId: agentA,
runId: runA,
action: "plugin.bridge.action_performed",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
pluginKey: "paperclip.example",
companyId: companyA,
bridgeMethod: "performAction",
actionKey: "sync",
routeStyle: "legacy",
outcome: "success",
}),
}));
});
it("rejects agent plugin actions outside the authenticated company scope", async () => {
@@ -926,6 +1032,41 @@ describe.sequential("plugin tool and bridge authz", () => {
});
});
it("audits company-scoped plugin log reads", async () => {
readyPlugin();
const limit = vi.fn(() => Promise.resolve([
{ id: "log-1", pluginId, companyId: companyA, level: "info", message: "ok", meta: null },
]));
const orderBy = vi.fn(() => ({ limit }));
const where = vi.fn(() => ({ orderBy }));
const from = vi.fn(() => ({ where }));
const db = { select: vi.fn(() => ({ from })) };
const { app } = await createApp(boardActor({ companyIds: [companyA], runId: runA }), {}, { db });
const res = await request(app)
.get(`/api/plugins/${pluginId}/logs?companyId=${companyA}&limit=10&level=info`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: companyA,
actorType: "user",
actorId: "user-1",
runId: runA,
action: "plugin.logs.read",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
pluginKey: "paperclip.example",
companyId: companyA,
limit: 10,
level: "info",
resultCount: 1,
}),
}));
});
it("rejects manual job triggers for non-admin board users", async () => {
const scheduler = { triggerJob: vi.fn() };
const jobStore = { getJobByIdForPlugin: vi.fn() };
@@ -945,22 +1086,52 @@ describe.sequential("plugin tool and bridge authz", () => {
it("allows manual job triggers for instance admins", async () => {
readyPlugin();
const scheduler = { triggerJob: vi.fn().mockResolvedValue({ runId: "run-1", jobId: "job-1" }) };
const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1" }) };
const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1", jobKey: "sync" }) };
const { app } = await createApp(boardActor({
userId: "admin-1",
isInstanceAdmin: true,
companyIds: [],
companyIds: [companyA],
runId: runA,
}), {}, {
jobDeps: { scheduler, jobStore },
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/jobs/job-1/trigger`)
.send({});
.send({ companyId: companyA });
expect(res.status).toBe(200);
expect(res.body).toEqual({ runId: "run-1", jobId: "job-1" });
expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual");
expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual", {
companyId: companyA,
attribution: {
triggeredByActorType: "user",
triggeredByActorId: "admin-1",
triggeredByAgentId: null,
triggeredByUserId: "admin-1",
triggeredByRunId: runA,
responsibleUserId: "admin-1",
},
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId: companyA,
actorType: "user",
actorId: "admin-1",
agentId: null,
runId: runA,
action: "plugin.job.triggered",
entityType: "plugin_job",
entityId: "job-1",
details: expect.objectContaining({
pluginId,
pluginKey: "paperclip.example",
companyId: companyA,
jobId: "job-1",
jobKey: "sync",
trigger: "manual",
pluginJobRunId: "run-1",
}),
}));
});
// ─── Agent JWT tool execution (cherry-picked from #5549) ─────────────────────
@@ -18,6 +18,8 @@ const mockIssueService = vi.hoisted(() => ({
assertCheckoutOwner: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
@@ -31,7 +33,7 @@ vi.mock("../services/issues.js", () => ({
}));
vi.mock("../services/activity-log.js", () => ({
logActivity: vi.fn(),
logActivity: mockLogActivity,
}));
vi.mock("../services/live-events.js", () => ({
@@ -156,6 +158,25 @@ describe.sequential("plugin scoped API routes", () => {
actor: expect.objectContaining({ actorType: "user", actorId: "user-1" }),
}));
expect(workerManager.call.mock.calls[0]?.[2].headers.authorization).toBeUndefined();
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId,
actorType: "user",
actorId: "user-1",
agentId: null,
runId: null,
action: "plugin.api_route.proxied",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
pluginKey: apiRoutes.id,
companyId,
routeKey: "summary.get",
method: "GET",
status: 201,
outcome: "success",
}),
}));
});
it("only forwards allowlisted response headers from plugin routes", async () => {
@@ -251,6 +272,25 @@ describe.sequential("plugin scoped API routes", () => {
actor: expect.objectContaining({ actorType: "agent", agentId, runId }),
companyId,
}));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId,
actorType: "agent",
actorId: agentId,
agentId,
runId,
action: "plugin.api_route.proxied",
entityType: "plugin",
entityId: pluginId,
details: expect.objectContaining({
pluginId,
pluginKey: apiRoutes.id,
companyId,
routeKey: "issue.advance",
method: "POST",
status: 200,
outcome: "success",
}),
}));
});
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRuns,
pluginEntities,
pluginJobs,
pluginJobRuns,
@@ -12,6 +14,7 @@ import {
plugins,
} from "@paperclipai/db";
import { buildHostServices, flushPluginLogBuffer } from "../services/plugin-host-services.js";
import { pluginJobStore } from "../services/plugin-job-store.js";
import { pluginRegistryService } from "../services/plugin-registry.js";
import {
getEmbeddedPostgresTestSupport,
@@ -59,6 +62,8 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
await db.delete(pluginLogs);
await db.delete(pluginWebhookDeliveries);
await db.delete(plugins);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
@@ -188,6 +193,68 @@ describeEmbeddedPostgres("plugin tenant isolation (company_id FK)", () => {
);
});
it("persists host-derived attribution on plugin_job_runs", async () => {
const pluginId = await seedPlugin();
const companyId = await seedCompany();
const agentId = randomUUID();
const runId = randomUUID();
const jobId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Plugin Runner",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
responsibleUserId: "run-user",
});
await db.insert(pluginJobs).values({
id: jobId,
pluginId,
jobKey: "manual-sync",
schedule: "* * * * *",
});
const run = await pluginJobStore(db).createRun({
jobId,
pluginId,
companyId,
trigger: "manual",
triggeredByActorType: "agent",
triggeredByActorId: agentId,
triggeredByAgentId: agentId,
triggeredByRunId: runId,
responsibleUserId: "run-user",
});
const row = await db
.select()
.from(pluginJobRuns)
.where(eq(pluginJobRuns.id, run.id))
.then((rows) => rows[0]);
expect(row).toMatchObject({
companyId,
trigger: "manual",
triggeredByActorType: "agent",
triggeredByActorId: agentId,
triggeredByAgentId: agentId,
triggeredByUserId: null,
triggeredByRunId: runId,
responsibleUserId: "run-user",
});
});
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();
+16 -5
View File
@@ -4892,9 +4892,17 @@ registry.registerPath({
method: "get",
path: "/api/plugins/{pluginId}/logs",
tags: ["plugins"],
summary: "Get plugin logs",
request: { params: z.object({ pluginId: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
summary: "Get company-scoped plugin logs",
request: {
params: z.object({ pluginId: z.string() }),
query: z.object({
companyId: z.string(),
limit: z.coerce.number().int().min(1).max(500).optional(),
level: z.string().optional(),
since: z.string().optional(),
}),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
@@ -4965,8 +4973,11 @@ registry.registerPath({
path: "/api/plugins/{pluginId}/jobs/{jobId}/trigger",
tags: ["plugins"],
summary: "Trigger a plugin job",
request: { params: z.object({ pluginId: z.string(), jobId: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
request: {
params: z.object({ pluginId: z.string(), jobId: z.string() }),
body: jsonBody(z.object({ companyId: z.string().optional() }).optional()),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
+256 -7
View File
@@ -53,11 +53,11 @@ import {
pluginLoader,
REPO_ROOT,
} from "../services/plugin-loader.js";
import { logActivity } from "../services/activity-log.js";
import { logActivity, resolveResponsibleUserIdForActivity } from "../services/activity-log.js";
import { publishGlobalLiveEvent } from "../services/live-events.js";
import { issueService } from "../services/issues.js";
import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js";
import type { PluginJobStore } from "../services/plugin-job-store.js";
import type { JobRunAttributionInput, PluginJobStore } from "../services/plugin-job-store.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
@@ -691,7 +691,19 @@ export function pluginRoutes(
entityId: string,
details: Record<string, unknown>,
): Promise<void> {
const companyIds = await resolvePluginAuditCompanyIds(req);
await logPluginAuditActivity(req, action, entityId, details);
}
async function logPluginAuditActivity(
req: Request,
action: string,
entityId: string,
details: Record<string, unknown>,
options: { companyId?: string | null; entityType?: string } = {},
): Promise<void> {
const companyIds = options.companyId
? [options.companyId]
: await resolvePluginAuditCompanyIds(req);
if (companyIds.length === 0) return;
const actor = getActorInfo(req);
@@ -704,12 +716,71 @@ export function pluginRoutes(
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action,
entityType: "plugin",
entityType: options.entityType ?? "plugin",
entityId,
details,
})));
}
async function logResolvedPluginActivity(
req: Request,
action: string,
plugin: { id: string; pluginKey: string },
details: Record<string, unknown>,
options: { companyId?: string | null; entityType?: string; entityId?: string } = {},
): Promise<void> {
await logPluginAuditActivity(req, action, options.entityId ?? plugin.id, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
...details,
}, {
companyId: options.companyId,
entityType: options.entityType,
});
}
async function buildPluginJobRunAttribution(
req: Request,
companyId: string | null,
jobId: string,
): Promise<JobRunAttributionInput> {
const actor = getActorInfo(req);
const responsibleUserId = companyId
? await resolveResponsibleUserIdForActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "plugin.job.triggered",
entityType: "plugin_job",
entityId: jobId,
})
: actor.actorType === "user"
? actor.actorId
: null;
return {
triggeredByActorType: actor.actorType,
triggeredByActorId: actor.actorId,
triggeredByAgentId: actor.agentId,
triggeredByUserId: actor.actorType === "user" ? actor.actorId : null,
triggeredByRunId: actor.runId,
responsibleUserId,
};
}
function readOptionalCompanyId(req: Request, value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) {
throw badRequest('"companyId" must be a non-empty string when provided');
}
const companyId = value.trim();
assertCompanyAccess(req, companyId);
return companyId;
}
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
if (companyId === undefined || companyId === null) {
assertInstanceAdmin(req);
@@ -1351,6 +1422,36 @@ export function pluginRoutes(
(res as any).err = rootError;
}
async function logPluginBridgeDataRead(
req: Request,
plugin: { id: string; pluginKey: string },
input: { companyId?: string; dataKey: string; routeStyle: "legacy" | "url"; outcome: "success" | "error"; bridgeCode?: string },
): Promise<void> {
await logResolvedPluginActivity(req, "plugin.bridge.data_read", plugin, {
companyId: input.companyId ?? null,
bridgeMethod: "getData",
dataKey: input.dataKey,
routeStyle: input.routeStyle,
outcome: input.outcome,
...(input.bridgeCode ? { bridgeCode: input.bridgeCode } : {}),
}, { companyId: input.companyId ?? null });
}
async function logPluginBridgeAction(
req: Request,
plugin: { id: string; pluginKey: string },
input: { companyId?: string; actionKey: string; routeStyle: "legacy" | "url"; outcome: "success" | "error"; bridgeCode?: string },
): Promise<void> {
await logResolvedPluginActivity(req, "plugin.bridge.action_performed", plugin, {
companyId: input.companyId ?? null,
bridgeMethod: "performAction",
actionKey: input.actionKey,
routeStyle: input.routeStyle,
outcome: input.outcome,
...(input.bridgeCode ? { bridgeCode: input.bridgeCode } : {}),
}, { companyId: input.companyId ?? null });
}
/**
* POST /api/plugins/:pluginId/bridge/data
*
@@ -1431,6 +1532,12 @@ export function pluginRoutes(
renderEnvironment: body.renderEnvironment ?? null,
},
);
await logPluginBridgeDataRead(req, plugin, {
companyId,
dataKey: body.key,
routeStyle: "legacy",
outcome: "success",
});
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
@@ -1440,6 +1547,13 @@ export function pluginRoutes(
bridgeMethod: "getData",
dataKey: body.key,
});
await logPluginBridgeDataRead(req, plugin, {
companyId,
dataKey: body.key,
routeStyle: "legacy",
outcome: "error",
bridgeCode: bridgeError.code,
});
res.status(502).json(bridgeError);
}
});
@@ -1524,6 +1638,12 @@ export function pluginRoutes(
renderEnvironment: body.renderEnvironment ?? null,
},
);
await logPluginBridgeAction(req, plugin, {
companyId,
actionKey: body.key,
routeStyle: "legacy",
outcome: "success",
});
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
@@ -1533,6 +1653,13 @@ export function pluginRoutes(
bridgeMethod: "performAction",
actionKey: body.key,
});
await logPluginBridgeAction(req, plugin, {
companyId,
actionKey: body.key,
routeStyle: "legacy",
outcome: "error",
bridgeCode: bridgeError.code,
});
res.status(502).json(bridgeError);
}
});
@@ -1618,6 +1745,12 @@ export function pluginRoutes(
renderEnvironment: body?.renderEnvironment ?? null,
},
);
await logPluginBridgeDataRead(req, plugin, {
companyId,
dataKey: key,
routeStyle: "url",
outcome: "success",
});
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
@@ -1627,6 +1760,13 @@ export function pluginRoutes(
bridgeMethod: "getData",
dataKey: key,
});
await logPluginBridgeDataRead(req, plugin, {
companyId,
dataKey: key,
routeStyle: "url",
outcome: "error",
bridgeCode: bridgeError.code,
});
res.status(502).json(bridgeError);
}
});
@@ -1708,6 +1848,12 @@ export function pluginRoutes(
renderEnvironment: body?.renderEnvironment ?? null,
},
);
await logPluginBridgeAction(req, plugin, {
companyId,
actionKey: key,
routeStyle: "url",
outcome: "success",
});
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
@@ -1717,6 +1863,13 @@ export function pluginRoutes(
bridgeMethod: "performAction",
actionKey: key,
});
await logPluginBridgeAction(req, plugin, {
companyId,
actionKey: key,
routeStyle: "url",
outcome: "error",
bridgeCode: bridgeError.code,
});
res.status(502).json(bridgeError);
}
});
@@ -1852,6 +2005,8 @@ export function pluginRoutes(
return;
}
let scopedApiAudit: { companyId: string; routeKey: string; routePath: string; method: string } | null = null;
try {
assertScopedApiAuth(req, match.route);
const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);
@@ -1860,6 +2015,12 @@ export function pluginRoutes(
return;
}
assertCompanyAccess(req, companyId);
scopedApiAudit = {
companyId,
routeKey: match.route.routeKey,
routePath: match.route.path,
method: req.method,
};
await enforceScopedApiCheckout(req, match.route, match.params, companyId);
if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) {
res.status(415).json({ error: "Plugin API routes accept JSON requests only" });
@@ -1899,6 +2060,16 @@ export function pluginRoutes(
const status = Number.isInteger(result.status) && Number(result.status) >= 200 && Number(result.status) <= 599
? Number(result.status)
: 200;
if (scopedApiAudit) {
await logResolvedPluginActivity(req, "plugin.api_route.proxied", plugin, {
companyId: scopedApiAudit.companyId,
routeKey: scopedApiAudit.routeKey,
routePath: scopedApiAudit.routePath,
method: scopedApiAudit.method,
status,
outcome: status >= 400 ? "error" : "success",
}, { companyId: scopedApiAudit.companyId });
}
applyPluginScopedApiResponseHeaders(res, result.headers);
if (status === 204) {
res.status(status).end();
@@ -1918,6 +2089,16 @@ export function pluginRoutes(
: err instanceof JsonRpcCallError
? 502
: 500;
if (scopedApiAudit) {
await logResolvedPluginActivity(req, "plugin.api_route.proxied", plugin, {
companyId: scopedApiAudit.companyId,
routeKey: scopedApiAudit.routeKey,
routePath: scopedApiAudit.routePath,
method: scopedApiAudit.method,
status,
outcome: "error",
}, { companyId: scopedApiAudit.companyId });
}
res.status(status).json({
error: err instanceof Error ? err.message : String(err),
});
@@ -2162,15 +2343,18 @@ export function pluginRoutes(
const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500);
const level = req.query.level as string | undefined;
const since = req.query.since as string | undefined;
const companyId = requirePluginConfigCompanyId(req, req.query.companyId);
const conditions = [eq(pluginLogs.pluginId, plugin.id)];
const conditions = [eq(pluginLogs.pluginId, plugin.id), eq(pluginLogs.companyId, companyId)];
if (level) {
conditions.push(eq(pluginLogs.level, level));
}
let sinceIso: string | null = null;
if (since) {
const sinceDate = new Date(since);
if (!isNaN(sinceDate.getTime())) {
conditions.push(gte(pluginLogs.createdAt, sinceDate));
sinceIso = sinceDate.toISOString();
}
}
@@ -2181,6 +2365,13 @@ export function pluginRoutes(
.orderBy(desc(pluginLogs.createdAt))
.limit(limit);
await logResolvedPluginActivity(req, "plugin.logs.read", plugin, {
companyId,
limit,
level: level ?? null,
since: sinceIso,
resultCount: rows.length,
}, { companyId });
res.json(rows);
});
@@ -2263,6 +2454,13 @@ export function pluginRoutes(
}
const config = await registry.getConfig(plugin.id, companyId);
await logResolvedPluginActivity(req, "plugin.config.read", plugin, {
companyId,
configPresent: Boolean(config),
configKeyCount: config?.configJson && typeof config.configJson === "object"
? Object.keys(config.configJson).length
: 0,
}, { companyId });
res.json(config);
});
@@ -2458,8 +2656,9 @@ export function pluginRoutes(
}
}
const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema);
try {
const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema);
await validatePluginSecretRefsForCompany(companyId, secretRefs);
const result = await bridgeDeps.workerManager.call(
@@ -2474,11 +2673,29 @@ export function pluginRoutes(
const warningText = result.warnings?.length
? `Warnings: ${result.warnings.join("; ")}`
: undefined;
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
companyId,
supported: true,
valid: true,
warningCount: result.warnings?.length ?? 0,
errorCount: 0,
secretRefCount: secretRefs.length,
configKeyCount: Object.keys(body.configJson).length,
}, { companyId });
res.json({ valid: true, message: warningText });
} else {
const errorText = result.errors?.length
? result.errors.join("; ")
: "Configuration validation failed.";
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
companyId,
supported: true,
valid: false,
warningCount: result.warnings?.length ?? 0,
errorCount: result.errors?.length ?? 0,
secretRefCount: secretRefs.length,
configKeyCount: Object.keys(body.configJson).length,
}, { companyId });
res.json({ valid: false, message: errorText });
}
} catch (err) {
@@ -2487,6 +2704,16 @@ export function pluginRoutes(
err instanceof JsonRpcCallError &&
err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
) {
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
companyId,
supported: false,
valid: false,
warningCount: 0,
errorCount: 0,
secretRefCount: secretRefs.length,
configKeyCount: Object.keys(body.configJson).length,
outcome: "unsupported",
}, { companyId });
res.json({
valid: false,
supported: false,
@@ -2497,6 +2724,15 @@ export function pluginRoutes(
// Worker unavailable or other RPC errors
const bridgeError = mapRpcErrorToBridgeError(err);
await logResolvedPluginActivity(req, "plugin.config.tested", plugin, {
companyId,
supported: true,
valid: false,
secretRefCount: secretRefs.length,
configKeyCount: Object.keys(body.configJson).length,
outcome: "bridge_error",
bridgeCode: bridgeError.code,
}, { companyId });
res.status(502).json(bridgeError);
}
});
@@ -2618,6 +2854,8 @@ export function pluginRoutes(
}
const { pluginId, jobId } = req.params;
const body = req.body as { companyId?: unknown } | undefined;
const companyId = readOptionalCompanyId(req, body?.companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
@@ -2631,7 +2869,18 @@ export function pluginRoutes(
}
try {
const result = await jobDeps.scheduler.triggerJob(jobId, "manual");
const attribution = await buildPluginJobRunAttribution(req, companyId ?? null, job.id);
const result = await jobDeps.scheduler.triggerJob(jobId, "manual", {
companyId: companyId ?? null,
attribution,
});
await logResolvedPluginActivity(req, "plugin.job.triggered", plugin, {
companyId: companyId ?? null,
jobId: job.id,
jobKey: typeof job.jobKey === "string" ? job.jobKey : null,
trigger: "manual",
pluginJobRunId: result.runId,
}, { companyId: companyId ?? null, entityType: "plugin_job", entityId: job.id });
res.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
+12 -2
View File
@@ -37,7 +37,7 @@
import { and, eq, lte, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { pluginJobs, pluginJobRuns } from "@paperclipai/db";
import type { PluginJobStore } from "./plugin-job-store.js";
import type { JobRunAttributionInput, PluginJobStore } from "./plugin-job-store.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
import { parseCron, nextCronTick, validateCron } from "./cron.js";
import { logger } from "../middleware/logger.js";
@@ -87,6 +87,13 @@ export interface TriggerJobResult {
jobId: string;
}
export interface TriggerJobOptions {
/** Host-authorized company scope for manual runs; null/omitted means instance scope. */
companyId?: string | null;
/** Host-derived attribution for manual/retry runs. */
attribution?: JobRunAttributionInput;
}
/**
* Diagnostic information about the scheduler.
*/
@@ -157,7 +164,7 @@ export interface PluginJobScheduler {
* @returns The created run info
* @throws {Error} if the job is not found, not active, or already running
*/
triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise<TriggerJobResult>;
triggerJob(jobId: string, trigger?: "manual" | "retry", options?: TriggerJobOptions): Promise<TriggerJobResult>;
/**
* Run a single scheduler tick immediately (for testing).
@@ -439,6 +446,7 @@ export function createPluginJobScheduler(
async function triggerJob(
jobId: string,
trigger: "manual" | "retry" = "manual",
triggerOptions: TriggerJobOptions = {},
): Promise<TriggerJobResult> {
const job = await jobStore.getJobById(jobId);
if (!job) {
@@ -486,7 +494,9 @@ export function createPluginJobScheduler(
const run = await jobStore.createRun({
jobId,
pluginId: job.pluginId,
companyId: triggerOptions.companyId ?? null,
trigger,
...triggerOptions.attribution,
});
// Dispatch in background — don't block the caller
+25 -1
View File
@@ -52,14 +52,31 @@ type JobDefinitionStatus = PluginJobRecord["status"];
// Types
// ---------------------------------------------------------------------------
export interface JobRunAttributionInput {
/** Host-derived actor type that triggered this run. */
triggeredByActorType?: "agent" | "user" | "system" | "plugin" | null;
/** Host-derived actor id that triggered this run. */
triggeredByActorId?: string | null;
/** Host-derived agent id that triggered this run, when applicable. */
triggeredByAgentId?: string | null;
/** Host-derived board/user id that triggered this run, when applicable. */
triggeredByUserId?: string | null;
/** Host-derived heartbeat run id that triggered this run, when applicable. */
triggeredByRunId?: string | null;
/** Responsible user resolved by the host for this run, when known. */
responsibleUserId?: string | null;
}
/**
* Input for creating a job run record.
*/
export interface CreateJobRunInput {
export interface CreateJobRunInput extends JobRunAttributionInput {
/** FK to the plugin_jobs row. */
jobId: string;
/** FK to the plugins row. */
pluginId: string;
/** Company scope for company-owned manual runs; null/omitted means instance scope. */
companyId?: string | null;
/** What triggered this run. */
trigger: PluginJobRunTrigger;
}
@@ -356,7 +373,14 @@ export function pluginJobStore(db: Db) {
.values({
jobId: input.jobId,
pluginId: input.pluginId,
companyId: input.companyId ?? null,
trigger: input.trigger,
triggeredByActorType: input.triggeredByActorType ?? null,
triggeredByActorId: input.triggeredByActorId ?? null,
triggeredByAgentId: input.triggeredByAgentId ?? null,
triggeredByUserId: input.triggeredByUserId ?? null,
triggeredByRunId: input.triggeredByRunId ?? null,
responsibleUserId: input.responsibleUserId ?? null,
status: "queued",
})
.returning();
+14
View File
@@ -698,6 +698,14 @@ export function pluginRegistryService(db: Db) {
jobId: string,
trigger: PluginJobRunTrigger,
companyId: string | null,
attribution?: {
triggeredByActorType?: "agent" | "user" | "system" | "plugin" | null;
triggeredByActorId?: string | null;
triggeredByAgentId?: string | null;
triggeredByUserId?: string | null;
triggeredByRunId?: string | null;
responsibleUserId?: string | null;
},
) => {
return db
.insert(pluginJobRuns)
@@ -706,6 +714,12 @@ export function pluginRegistryService(db: Db) {
jobId,
companyId,
trigger,
triggeredByActorType: attribution?.triggeredByActorType ?? null,
triggeredByActorId: attribution?.triggeredByActorId ?? null,
triggeredByAgentId: attribution?.triggeredByAgentId ?? null,
triggeredByUserId: attribution?.triggeredByUserId ?? null,
triggeredByRunId: attribution?.triggeredByRunId ?? null,
responsibleUserId: attribution?.responsibleUserId ?? null,
status: "pending",
})
.returning()
+6 -6
View File
@@ -301,13 +301,13 @@ export const pluginsApi = {
* Fetch recent log entries for a plugin.
*
* @param pluginId - UUID of the plugin.
* @param options - Optional filters: limit, level, since.
* @param options - Company scope plus optional filters: limit, level, since.
*/
logs: (pluginId: string, options?: { limit?: number; level?: string; since?: string }) => {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
if (options?.level) params.set("level", options.level);
if (options?.since) params.set("since", options.since);
logs: (pluginId: string, options: { companyId: string; limit?: number; level?: string; since?: string }) => {
const params = new URLSearchParams({ companyId: options.companyId });
if (options.limit) params.set("limit", String(options.limit));
if (options.level) params.set("level", options.level);
if (options.since) params.set("since", options.since);
const qs = params.toString();
return api.get<Array<{ id: string; pluginId: string; level: string; message: string; meta: Record<string, unknown> | null; createdAt: string }>>(
`/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`,
+14
View File
@@ -82,6 +82,13 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"company.archived": "archived",
"company.reactivated": "reactivated",
"company.budget_updated": "updated budget for",
"plugin.config.read": "read configuration for",
"plugin.config.tested": "tested configuration for",
"plugin.bridge.data_read": "read bridge data from",
"plugin.bridge.action_performed": "performed bridge action on",
"plugin.api_route.proxied": "proxied API route for",
"plugin.job.triggered": "triggered job for",
"plugin.logs.read": "read logs for",
};
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
@@ -129,6 +136,13 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
"plugin.config.read": "read plugin configuration",
"plugin.config.tested": "tested plugin configuration",
"plugin.bridge.data_read": "read plugin bridge data",
"plugin.bridge.action_performed": "performed plugin bridge action",
"plugin.api_route.proxied": "proxied a plugin API route",
"plugin.job.triggered": "triggered a plugin job",
"plugin.logs.read": "read plugin logs",
};
function asRecord(value: unknown): Record<string, unknown> | null {
+1 -1
View File
@@ -404,7 +404,7 @@ export const queryKeys = {
localFolders: (pluginId: string, companyId: string) =>
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
logs: (pluginId: string, companyId: string) => ["plugins", pluginId, "companies", companyId, "logs"] as const,
},
adapters: {
all: ["adapters"] as const,
+5 -3
View File
@@ -86,9 +86,11 @@ export function PluginSettings() {
});
const { data: recentLogs } = useQuery({
queryKey: queryKeys.plugins.logs(pluginId!),
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
enabled: !!pluginId && plugin?.status === "ready",
queryKey: pluginId && selectedCompanyId
? queryKeys.plugins.logs(pluginId, selectedCompanyId)
: ["plugins", pluginId ?? "__missing_plugin__", "companies", "__missing_company__", "logs"] as const,
queryFn: () => pluginsApi.logs(pluginId!, { companyId: selectedCompanyId!, limit: 50 }),
enabled: !!pluginId && !!selectedCompanyId && plugin?.status === "ready",
refetchInterval: 30000,
});