diff --git a/packages/db/src/migrations/0193_plugin_job_run_attribution.sql b/packages/db/src/migrations/0193_plugin_job_run_attribution.sql new file mode 100644 index 00000000000..6a22afdbd76 --- /dev/null +++ b/packages/db/src/migrations/0193_plugin_job_run_attribution.sql @@ -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; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c99cd8db4ef..6c9b396e4c9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/plugin_jobs.ts b/packages/db/src/schema/plugin_jobs.ts index 91dd30bd36c..972ec35f3f3 100644 --- a/packages/db/src/schema/plugin_jobs.ts +++ b/packages/db/src/schema/plugin_jobs.ts @@ -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().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().notNull().default("pending"), /** Wall-clock duration in milliseconds. Null until the run finishes. */ diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 2ff9334382f..9544ce54899 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -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. */ diff --git a/server/src/__tests__/activity-log-responsible-user.test.ts b/server/src/__tests__/activity-log-responsible-user.test.ts index d631ff15fbc..a89e40dd17d 100644 --- a/server/src/__tests__/activity-log-responsible-user.test.ts +++ b/server/src/__tests__/activity-log-responsible-user.test.ts @@ -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", + }); + }); }); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index c7a0844b519..6bd1a53dd17 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -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(); + 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) ───────────────────── diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts index 4e2ce297bc0..cc0e2c6dc15 100644 --- a/server/src/__tests__/plugin-scoped-api-routes.test.ts +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -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 () => { diff --git a/server/src/__tests__/plugin-tenant-isolation.test.ts b/server/src/__tests__/plugin-tenant-isolation.test.ts index 83694ff0244..c49d18460d6 100644 --- a/server/src/__tests__/plugin-tenant-isolation.test.ts +++ b/server/src/__tests__/plugin-tenant-isolation.test.ts @@ -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(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 9ec800e9f89..c4f13e4ff16 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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({ diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 54681c58183..80dfbc19da9 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -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, ): Promise { - const companyIds = await resolvePluginAuditCompanyIds(req); + await logPluginAuditActivity(req, action, entityId, details); + } + + async function logPluginAuditActivity( + req: Request, + action: string, + entityId: string, + details: Record, + options: { companyId?: string | null; entityType?: string } = {}, + ): Promise { + 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, + options: { companyId?: string | null; entityType?: string; entityId?: string } = {}, + ): Promise { + 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 { + 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 { + 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 { + 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); diff --git a/server/src/services/plugin-job-scheduler.ts b/server/src/services/plugin-job-scheduler.ts index 09a6b8782b9..2f712357a0b 100644 --- a/server/src/services/plugin-job-scheduler.ts +++ b/server/src/services/plugin-job-scheduler.ts @@ -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; + triggerJob(jobId: string, trigger?: "manual" | "retry", options?: TriggerJobOptions): Promise; /** * 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 { 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 diff --git a/server/src/services/plugin-job-store.ts b/server/src/services/plugin-job-store.ts index f5fdbce1a42..a864fb6aa1c 100644 --- a/server/src/services/plugin-job-store.ts +++ b/server/src/services/plugin-job-store.ts @@ -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(); diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 9e2da319546..aa8c011bd02 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -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() diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 412f87fe960..ddcd904a21b 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -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 | null; createdAt: string }>>( `/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`, diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index f0fee3dbda6..b5b5d9be191 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -82,6 +82,13 @@ const ACTIVITY_ROW_VERBS: Record = { "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 = { @@ -129,6 +136,13 @@ const ISSUE_ACTIVITY_LABELS: Record = { "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 | null { diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 9738a432fd2..6f81d2001b8 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/PluginSettings.tsx b/ui/src/pages/PluginSettings.tsx index 9ae5ed37a85..3ce111751f4 100644 --- a/ui/src/pages/PluginSettings.tsx +++ b/ui/src/pages/PluginSettings.tsx @@ -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, });